feat(editor): integrate openreel-video as services/editor with MAM hooks

Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
This commit is contained in:
Zac Gaetano 2026-05-17 21:44:15 -04:00
parent 562881f0db
commit b68f0c6aba
667 changed files with 216368 additions and 1 deletions

4
.gitignore vendored
View file

@ -19,3 +19,7 @@ build/
.DS_Store
.env.swp
.env.swo
services/editor/node_modules
services/editor/**/node_modules
services/editor/**/dist
services/editor/.pnpm-store

View file

@ -90,6 +90,16 @@ services:
networks:
- wild-dragon
editor:
build: ./services/editor
depends_on:
- mam-api
ports:
- "${PORT_EDITOR:-47435}:80"
networks:
- wild-dragon
volumes:
postgres_data:
redis_data:

65
services/editor/.gitignore vendored Normal file
View file

@ -0,0 +1,65 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
.next/
out/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
# Testing
coverage/
.nyc_output/
# Temporary files
*.tmp
.cache/
.temp/
.docs/
docs/
# Project-specific
/public/projects/
*.openreel
apps/cloud/
apps/ios
apps/android
# Local files
FEATURES_TWITTER.md
.claude-tasks.md
CLAUDE.md
*-PLAN.md
*-PLAN-*.md
.playwright-mcp/
.wrangler/
mobile-mockup/

2
services/editor/.serena/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/cache
/project.local.yml

View file

@ -0,0 +1,135 @@
# the name by which the project can be referenced within Serena
project_name: "openreel-video"
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp
# csharp_omnisharp dart elixir elm erlang
# fortran fsharp go groovy haskell
# java julia kotlin lua markdown
# matlab nix pascal perl php
# php_phpactor powershell python python_jedi r
# rego ruby ruby_solargraph rust scala
# swift terraform toml typescript typescript_vts
# vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

View file

@ -0,0 +1,387 @@
# Contributing to OpenReel
Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
## Code of Conduct
Be respectful, constructive, and professional. We're building something great together!
## Getting Started
### Prerequisites
- Node.js 18 or higher
- pnpm (recommended) or npm
- Git
- Modern browser with WebCodecs support (Chrome 94+, Edge 94+)
### Development Setup
```bash
# 1. Fork and clone the repository
git clone https://github.com/Augani/openreel-video.git
cd openreel-video
# 2. Install dependencies
pnpm install
# 3. Start development server
pnpm dev
# 4. Open browser to http://localhost:5173
```
## Project Structure
```
openreel/
├── apps/
│ └── web/ # Main web application
│ ├── public/ # Static assets
│ └── src/
│ ├── components/ # React components
│ ├── stores/ # State management (Zustand)
│ ├── bridges/ # Core engine bridges
│ └── services/ # Business logic
├── packages/
│ └── core/ # Shared core logic
│ ├── src/
│ │ ├── actions/ # Action system
│ │ ├── video/ # Video processing
│ │ ├── audio/ # Audio processing
│ │ ├── graphics/ # Graphics & SVG
│ │ ├── text/ # Text & titles
│ │ └── export/ # Export engine
│ └── types/ # TypeScript types
```
## Coding Standards
### TypeScript
- **Strict mode**: Always use TypeScript strict mode
- **Types**: Prefer interfaces over types for object shapes
- **No `any`**: Avoid `any` - use `unknown` or proper types
- **Naming**:
- Components: `PascalCase` (e.g., `Timeline`, `Preview`)
- Functions: `camelCase` (e.g., `handleClick`, `processVideo`)
- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`)
- Files: `kebab-case.tsx` or `PascalCase.tsx` for components
### Code Style
```typescript
// ✅ Good
interface VideoClip {
id: string;
duration: number;
startTime: number;
}
function processClip(clip: VideoClip): ProcessedClip {
if (!clip.id) {
throw new Error('Clip ID is required');
}
return {
...clip,
processed: true,
};
}
// ❌ Avoid
function processClip(clip: any) {
console.log('Processing...'); // Remove debug logs
const result = clip; // Unclear what's happening
return result;
}
```
### React Components
```typescript
// ✅ Good
interface TimelineProps {
tracks: Track[];
onClipSelect: (clipId: string) => void;
}
export const Timeline: React.FC<TimelineProps> = ({ tracks, onClipSelect }) => {
const handleClick = useCallback((id: string) => {
onClipSelect(id);
}, [onClipSelect]);
return (
<div className="timeline">
{tracks.map(track => (
<Track key={track.id} track={track} onClick={handleClick} />
))}
</div>
);
};
```
### 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! 🎬

View file

@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++ git bash
RUN corepack enable && corepack prepare pnpm@9.7.0 --activate
WORKDIR /build
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json mediabunny.d.ts ./
COPY apps ./apps
COPY packages ./packages
RUN pnpm install --frozen-lockfile=false
RUN pnpm build:wasm || echo "no wasm build step, continuing"
RUN pnpm --filter @openreel/web build
RUN ls -la apps/web/dist
FROM nginx:alpine AS runtime
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
COPY --from=builder /build/apps/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -0,0 +1,20 @@
# Z-AMPP <-> openreel-video integration
Vendored from https://github.com/Augani/openreel-video (MIT). The upstream .git directory was removed so this lives as plain source we can patch freely.
## Files added (Z-AMPP-only, not upstream)
- Dockerfile, nginx.conf, VENDOR.txt, INTEGRATION.md
- apps/web/src/mam-bridge.ts: boot hook + pickFromMAM() modal
- packages/core/src/export/mam-export-target.ts: helpers for upload-to-MAM
## Upstream files patched
- apps/web/package.json: build script changed `tsc --noEmit && vite build` -> `vite build`. Original preserved as build:strict. (upstream tsc fails on pre-existing WebGPU + import.meta errors.)
- apps/web/src/bridges/media-bridge.ts: appended importFromURL(url, name, contentType?) as the last method of the MediaBridge class.
- apps/web/src/main.tsx: appended `import "./mam-bridge";` so the bridge boot hook runs.
## Query params honored
- ?asset=<uuid> auto-imports that asset on load.
- ?project=<uuid> stored in localStorage.mamProjectId for save-to-MAM.
## Ports
Container exposes 80; compose maps ${PORT_EDITOR:-47435}:80.

21
services/editor/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Augustus Otu and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

308
services/editor/README.md Normal file
View file

@ -0,0 +1,308 @@
# OpenReel Video
> **The open source CapCut alternative. Professional video editing in your browser. No uploads. No installs. 100% open source.**
OpenReel Video is a fully-featured browser-based video editor that runs entirely client-side. Built with React, TypeScript, WebCodecs, and WebGPU for professional-grade video editing without the need for expensive software or cloud processing.
**[Try it Live](https://openreel.video)** | **[Documentation](CONTRIBUTING.md)** | **[Discussions](https://github.com/Augani/openreel-video/discussions)** | **[Twitter](https://x.com/python_xi)**
![OpenReel Editor](https://img.shields.io/badge/Lines%20of%20Code-130k+-blue) ![License](https://img.shields.io/badge/License-MIT-green) ![Status](https://img.shields.io/badge/Status-Beta-orange) ![Open Source](https://img.shields.io/badge/Open%20Source-100%25-brightgreen)
---
## Why OpenReel?
- **100% Client-Side** - Your videos never leave your device. No uploads, no cloud processing, complete privacy.
- **No Installation** - Works in Chrome/Edge. Just open and start editing.
- **Professional Features** - Multi-track timeline, keyframe animations, color grading, audio effects, and more.
- **GPU Accelerated** - WebGPU and WebCodecs for smooth 4K editing and fast exports.
- **Free Forever** - MIT licensed, no subscriptions, no watermarks.
---
## Features
### Video Editing
- **Multi-track timeline** - Unlimited video, audio, image, text, and graphics tracks
- **Real-time preview** - Smooth playback with GPU acceleration
- **Precision editing** - Frame-accurate scrubbing, cut, trim, split, ripple delete
- **Transitions** - Crossfade, dip to black/white, wipe, slide effects
- **Video effects** - Brightness, contrast, saturation, blur, sharpen, glow, vignette, chroma key
- **Blend modes** - Multiply, screen, overlay, add, subtract, and more
- **Speed control** - 0.25x to 4x with audio pitch preservation
- **Crop & transform** - Position, scale, rotation with 3D perspective
### Graphics & Text
- **Professional text editor** - Rich styling, shadows, outlines, gradients
- **20+ text animations** - Typewriter, fade, slide, bounce, pop, elastic, glitch
- **Karaoke-style subtitles** - Word-by-word highlighting synced to audio
- **Shape tools** - Rectangle, circle, arrow, polygon, star with fill/stroke
- **SVG support** - Import SVGs with color tinting and animations
- **Stickers & emoji** - Built-in library
- **Background generator** - Solid colors, gradients, mesh gradients, patterns
- **Keyframe animations** - Animate any property over time with 20+ easing curves
### Audio
- **Multi-track mixing** - Unlimited audio tracks with real-time mixing
- **Waveform visualization** - Visual audio editing
- **Audio effects** - EQ, compressor, reverb, delay, chorus, flanger, distortion
- **Volume & panning** - Per-clip controls with fade in/out
- **Beat detection** - Auto-generate markers synced to music
- **Audio ducking** - Auto-reduce music when dialog plays
- **Noise reduction** - 3-pass noise removal (tonal, broadband, rumble)
### Color Grading
- **Color wheels** - Lift, gamma, gain controls
- **HSL adjustments** - Hue, saturation, lightness fine-tuning
- **Curves editor** - RGB and individual channel curves
- **LUT support** - Import and apply 3D LUTs
- **Built-in presets** - One-click color grading
### Export
- **MP4 (H.264/H.265)** - Universal compatibility
- **WebM (VP8/VP9/AV1)** - Web-optimized format
- **ProRes** - Professional intermediate format (Proxy, LT, Standard, HQ, 4444)
- **Quality presets** - 4K @ 60fps, 1080p, 720p, 480p
- **Custom settings** - Bitrate, frame rate, codec options, color depth
- **Hardware encoding** - WebCodecs for fast exports
- **AI upscaling** - Enhance resolution with WebGPU shaders
- **Audio export** - MP3, WAV, AAC, FLAC, OGG
- **Image sequences** - JPG, PNG, WebP frame export
- **Progress tracking** - Real-time progress with cancel support
### Professional Tools
- **Unlimited undo/redo** - Full history with recovery
- **Auto-save** - Never lose work (IndexedDB storage)
- **Keyboard shortcuts** - Professional workflow
- **Snap to grid** - Magnetic alignment
- **Track management** - Show/hide, lock/unlock, reorder
- **Subtitle support** - SRT import with customizable styling
- **Screen recording** - Record screen, camera, or both
- **Project sharing** - Export/import project files
### Performance
- **WebGPU rendering** - GPU-accelerated compositing
- **WebCodecs API** - Hardware video decoding/encoding
- **Frame caching** - LRU cache for smooth playback
- **Web Workers** - Background processing
- **4K support** - Edit and export in 4K resolution
---
## Quick Start
### Try Online
Visit **[openreel.video](https://openreel.video)** to start editing immediately.
### Run Locally
```bash
# Clone the repository
git clone https://github.com/Augani/openreel-video.git
cd openreel-video
# Install dependencies (requires Node.js 18+)
pnpm install
# Start development server
pnpm dev
# Open http://localhost:5173
```
### Build for Production
```bash
pnpm build
pnpm preview
```
---
## Browser Requirements
| Browser | Version | Status |
|---------|---------|--------|
| Chrome | 94+ | Full support |
| Edge | 94+ | Full support |
| Firefox | 130+ | Full support |
| Safari | 16.4+ | Full support |
All major browsers now support WebCodecs for hardware-accelerated video encoding/decoding.
**Recommended:**
- 8GB+ RAM
- Dedicated GPU for 4K editing
- Modern multi-core CPU
---
## Architecture
### Monorepo Structure
```
openreel/
├── apps/web/ # React frontend (~66k lines)
│ └── src/
│ ├── components/ # UI components
│ │ └── editor/ # Editor panels (Timeline, Preview, Inspector)
│ ├── stores/ # Zustand state management
│ ├── services/ # Auto-save, shortcuts, screen recording
│ └── bridges/ # Engine coordination
└── packages/core/ # Core engines (~59k lines)
└── src/
├── video/ # Video processing, WebGPU rendering
├── audio/ # Web Audio API, effects, beat detection
├── graphics/ # Canvas/THREE.js, shapes, SVG
├── text/ # Text rendering, animations
├── export/ # MP4/WebM encoding
└── storage/ # IndexedDB, serialization
```
### Key Technologies
- **React 18** + **TypeScript** - Type-safe UI
- **Zustand** - Lightweight state management
- **MediaBunny** - Video/audio processing
- **WebCodecs** - Hardware encoding/decoding
- **WebGPU** - GPU-accelerated rendering
- **Web Audio API** - Professional audio processing
- **THREE.js** - 3D transforms and effects
- **IndexedDB** - Local project storage
### Design Principles
- **Action-based editing** - Every edit is an undoable action
- **Immutable state** - Predictable updates with Zustand
- **Engine separation** - Video, audio, graphics engines are independent
- **Progressive enhancement** - Graceful fallbacks (WebGPU → Canvas2D)
---
## AI-Managed Development
OpenReel is an experiment in AI-assisted open source development. Claude AI helps manage:
- **Issue triage** - Reviews and responds to issues
- **Code implementation** - Writes features and fixes bugs
- **Code review** - Maintains quality standards
- **Documentation** - Keeps docs up to date
Human oversight from Augustus ensures strategic direction and final approval on major changes. All code is public, tested, and follows best practices.
**What this means for contributors:**
- Issues get reviewed quickly (usually within 24 hours)
- Bug fixes ship fast
- Clear, detailed responses to questions
- High code quality standards
---
## Contributing
We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
**Ways to contribute:**
- Report bugs with reproduction steps
- Suggest features in Discussions
- Submit PRs for bugs or features
- Improve documentation
- Write tests
- Share effect presets
**Development workflow:**
```bash
# Fork and clone
git clone https://github.com/Augani/openreel-video.git
# Create feature branch
git checkout -b feat/your-feature
# Make changes, then test
pnpm typecheck
pnpm test
pnpm lint
# Commit with conventional commits
git commit -m "feat: add your feature"
# Push and open PR
git push origin feat/your-feature
```
---
## Roadmap
### Completed
- Multi-track timeline with drag-and-drop
- Real-time video preview with GPU acceleration
- Full editing suite (cut, trim, split, transitions)
- Text editor with 20+ animations
- Graphics (shapes, SVG, stickers, backgrounds)
- Audio mixing with effects and beat detection
- Color grading with LUT support
- Keyframe animation system
- Export to MP4/WebM (4K supported)
- Screen recording
- AI upscaling
- Undo/redo with auto-save
### In Progress
- Nested sequences (timeline in timeline)
- Motion tracking
- More export formats (ProRes, GIF)
- Plugin system
### Planned
- Adjustment layers
- Advanced masking
- Audio spectral editing
- Collaborative editing
- Mobile optimization
---
## License
MIT License - Use freely for personal and commercial projects.
See [LICENSE](LICENSE) for details.
---
## Acknowledgments
**Built with:**
- [MediaBunny](https://mediabunny.dev) - Media processing
- [React](https://react.dev) - UI framework
- [Zustand](https://zustand-demo.pmnd.rs/) - State management
- [THREE.js](https://threejs.org) - 3D rendering
- [TailwindCSS](https://tailwindcss.com) - Styling
**Inspired by:**
- DaVinci Resolve - Professional tools done right
- CapCut - Accessible editing for everyone
- Figma - Browser-based professional software
---
## Support
- **GitHub Issues** - Bug reports and feature requests
- **GitHub Discussions** - Questions and community chat
- **Twitter/X** - [@python_xi](https://x.com/python_xi)
---
## $OPENREEL Token
CA: `B7wDnfrdtvdG7SCkRjSMJ6LkVwGWvdWrQ75iV8G9pump`
---
**Built with care by [@python_xi](https://x.com/python_xi) and AI working together.**
*Making professional video editing accessible to everyone. Forever free. Forever open source.*

View file

@ -0,0 +1 @@
Vendored from Augani/openreel-video @ 2026-05-18T01:29:08Z

View file

@ -0,0 +1,70 @@
import js from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
import reactHooks from "eslint-plugin-react-hooks";
import globals from "globals";
export default [
js.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
NodeJS: "readonly",
CanvasTextAlign: "readonly",
CanvasTextBaseline: "readonly",
CanvasLineCap: "readonly",
CanvasLineJoin: "readonly",
CanvasFillRule: "readonly",
GlobalCompositeOperation: "readonly",
ImageBitmap: "readonly",
OffscreenCanvas: "readonly",
OffscreenCanvasRenderingContext2D: "readonly",
React: "readonly",
JSX: "readonly",
},
},
plugins: {
"@typescript-eslint": tseslint,
"react-hooks": reactHooks,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"no-console": ["warn", { allow: ["warn", "error"] }],
"prefer-const": "warn",
"no-unused-vars": "off",
"no-empty": "warn",
"no-case-declarations": "warn",
"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
},
linterOptions: {
reportUnusedDisableDirectives: false,
},
},
{
ignores: [
"dist/**",
"node_modules/**",
"*.config.js",
"*.config.ts",
"vite.config.ts",
],
},
];

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#22c55e" />
<meta name="description" content="Professional browser-based graphic design editor - Create stunning visuals offline" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=DM+Sans:wght@400;500;700&family=Poppins:wght@300;400;500;600;700;800;900&family=Montserrat:wght@300;400;500;600;700;800;900&family=Playfair+Display:wght@400;500;600;700;800;900&family=Roboto:wght@300;400;500;700;900&family=Open+Sans:wght@300;400;600;700;800&family=Lato:wght@300;400;700;900&family=Oswald:wght@300;400;500;600;700&family=Bebas+Neue&family=Pacifico&family=Lobster&family=Dancing+Script:wght@400;700&family=Great+Vibes&display=swap" rel="stylesheet" />
<title>OpenReel Image - Professional Graphic Design Editor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>

View file

@ -0,0 +1,64 @@
{
"name": "@openreel/image",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"deploy": "wrangler pages deploy dist --project-name=openreel-image",
"deploy:preview": "wrangler pages deploy dist --project-name=openreel-image --branch=preview",
"test": "vitest",
"test:run": "vitest run",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules/.vite"
},
"dependencies": {
"@imgly/background-removal": "^1.7.0",
"@openreel/image-core": "workspace:*",
"@openreel/ui": "workspace:*",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.555.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^3.4.0",
"uuid": "^13.0.0",
"zod": "^4.4.3",
"zustand": "^4.5.2"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.53.0",
"@typescript-eslint/parser": "^8.53.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"globals": "^17.0.0",
"jsdom": "^24.1.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vite": "^5.3.1",
"vitest": "^1.6.0",
"wrangler": "^3.114.17"
}
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#22c55e"/>
<rect x="20" y="20" width="60" height="60" rx="8" fill="white" fill-opacity="0.9"/>
<circle cx="35" cy="38" r="8" fill="#22c55e"/>
<path d="M20 65 L45 45 L60 55 L80 35 L80 72 A8 8 0 0 1 72 80 L28 80 A8 8 0 0 1 20 72 Z" fill="#22c55e" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View file

@ -0,0 +1,19 @@
{
"name": "OpenReel Image",
"short_name": "OpenReel",
"description": "Professional browser-based graphic design editor",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#22c55e",
"orientation": "landscape",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"categories": ["graphics", "design", "productivity"]
}

View file

@ -0,0 +1,47 @@
const CACHE_NAME = 'openreel-image-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
if (url.origin !== location.origin) return;
event.respondWith(
caches.match(event.request).then((cached) => {
const fetchPromise = fetch(event.request)
.then((response) => {
if (response.ok && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => cached);
return cached || fetchPromise;
})
);
});

View file

@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useUIStore } from './stores/ui-store';
import { WelcomeScreen } from './components/welcome/WelcomeScreen';
import { EditorInterface } from './components/editor/EditorInterface';
import { KeyboardShortcutsPanel } from './components/editor/KeyboardShortcutsPanel';
import { SettingsDialog } from './components/editor/SettingsDialog';
import { useKeyboardShortcuts } from './services/keyboard-service';
import { useAutoSave } from './hooks/useAutoSave';
export default function App() {
const { currentView, setCurrentView, showShortcutsPanel, toggleShortcutsPanel, showSettingsDialog, closeSettingsDialog } = useUIStore();
useKeyboardShortcuts();
useAutoSave();
useEffect(() => {
document.documentElement.classList.add('dark');
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && currentView === 'editor') {
setCurrentView('welcome');
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentView, setCurrentView]);
return (
<div className="h-full w-full bg-background">
{currentView === 'welcome' && <WelcomeScreen />}
{currentView === 'editor' && <EditorInterface />}
<KeyboardShortcutsPanel isOpen={showShortcutsPanel} onClose={toggleShortcutsPanel} />
<SettingsDialog isOpen={showSettingsDialog} onClose={closeSettingsDialog} />
</div>
);
}

View file

@ -0,0 +1,168 @@
export interface BlackWhiteSettings {
reds: number;
yellows: number;
greens: number;
cyans: number;
blues: number;
magentas: number;
tint: {
enabled: boolean;
hue: number;
saturation: number;
};
}
export const DEFAULT_BLACK_WHITE: BlackWhiteSettings = {
reds: 40,
yellows: 60,
greens: 40,
cyans: 60,
blues: 20,
magentas: 80,
tint: {
enabled: false,
hue: 30,
saturation: 25,
},
};
export const BLACK_WHITE_PRESETS = {
default: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
highContrast: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
infrared: { reds: -70, yellows: 200, greens: -70, cyans: 200, blues: -20, magentas: -20 },
maximumWhite: { reds: 100, yellows: 100, greens: 100, cyans: 100, blues: 100, magentas: 100 },
maximumBlack: { reds: -200, yellows: -200, greens: -200, cyans: -200, blues: -200, magentas: -200 },
neutral: { reds: 33, yellows: 33, greens: 33, cyans: 33, blues: 33, magentas: 33 },
redFilter: { reds: 106, yellows: 52, greens: -10, cyans: -40, blues: -30, magentas: 94 },
yellowFilter: { reds: 34, yellows: 106, greens: 54, cyans: -26, blues: -50, magentas: 14 },
greenFilter: { reds: -44, yellows: 64, greens: 106, cyans: 60, blues: -30, magentas: -70 },
blueFilter: { reds: -30, yellows: -46, greens: -16, cyans: 30, blues: 106, magentas: 30 },
};
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return { h, s, l };
}
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
if (s === 0) {
const gray = Math.round(l * 255);
return { r: gray, g: gray, b: gray };
}
const hue2rgb = (p: number, q: number, t: number): number => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
};
}
function getColorWeight(hue: number, targetHue: number, spread: number = 60): number {
let diff = Math.abs(hue - targetHue);
if (diff > 180) diff = 360 - diff;
if (diff >= spread) return 0;
return 1 - diff / spread;
}
export function applyBlackWhite(imageData: ImageData, settings: BlackWhiteSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const { h, s } = rgbToHsl(r, g, b);
const hue = h * 360;
let gray = (r + g + b) / 3;
if (s > 0.05) {
const redWeight = getColorWeight(hue, 0) + getColorWeight(hue, 360);
const yellowWeight = getColorWeight(hue, 60);
const greenWeight = getColorWeight(hue, 120);
const cyanWeight = getColorWeight(hue, 180);
const blueWeight = getColorWeight(hue, 240);
const magentaWeight = getColorWeight(hue, 300);
const totalWeight = redWeight + yellowWeight + greenWeight + cyanWeight + blueWeight + magentaWeight;
if (totalWeight > 0) {
const adjustment =
(redWeight * settings.reds +
yellowWeight * settings.yellows +
greenWeight * settings.greens +
cyanWeight * settings.cyans +
blueWeight * settings.blues +
magentaWeight * settings.magentas) / totalWeight;
gray = gray * (1 + (adjustment - 50) / 100 * s);
}
}
gray = Math.max(0, Math.min(255, gray));
let finalR = gray;
let finalG = gray;
let finalB = gray;
if (settings.tint.enabled) {
const tintH = settings.tint.hue / 360;
const tintS = settings.tint.saturation / 100;
const tintL = gray / 255;
const tinted = hslToRgb(tintH, tintS, tintL);
finalR = tinted.r;
finalG = tinted.g;
finalB = tinted.b;
}
resultData[i] = finalR;
resultData[i + 1] = finalG;
resultData[i + 2] = finalB;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,108 @@
export interface ChannelMixerSettings {
red: {
red: number;
green: number;
blue: number;
constant: number;
};
green: {
red: number;
green: number;
blue: number;
constant: number;
};
blue: {
red: number;
green: number;
blue: number;
constant: number;
};
monochrome: boolean;
monoRed: number;
monoGreen: number;
monoBlue: number;
monoConstant: number;
}
export const DEFAULT_CHANNEL_MIXER: ChannelMixerSettings = {
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
monochrome: false,
monoRed: 40,
monoGreen: 40,
monoBlue: 20,
monoConstant: 0,
};
export const CHANNEL_MIXER_PRESETS = {
default: {
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
},
swapRedBlue: {
red: { red: 0, green: 0, blue: 100, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 100, green: 0, blue: 0, constant: 0 },
},
sepia: {
red: { red: 100, green: 50, blue: 0, constant: 0 },
green: { red: 60, green: 60, blue: 0, constant: 0 },
blue: { red: 30, green: 30, blue: 30, constant: 0 },
},
cyberPunk: {
red: { red: 100, green: 0, blue: 50, constant: 0 },
green: { red: 0, green: 100, blue: 50, constant: 0 },
blue: { red: 50, green: 0, blue: 100, constant: 0 },
},
};
export function applyChannelMixer(imageData: ImageData, settings: ChannelMixerSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let newR: number, newG: number, newB: number;
if (settings.monochrome) {
const gray =
r * (settings.monoRed / 100) +
g * (settings.monoGreen / 100) +
b * (settings.monoBlue / 100) +
settings.monoConstant * 2.55;
newR = newG = newB = Math.max(0, Math.min(255, gray));
} else {
newR =
r * (settings.red.red / 100) +
g * (settings.red.green / 100) +
b * (settings.red.blue / 100) +
settings.red.constant * 2.55;
newG =
r * (settings.green.red / 100) +
g * (settings.green.green / 100) +
b * (settings.green.blue / 100) +
settings.green.constant * 2.55;
newB =
r * (settings.blue.red / 100) +
g * (settings.blue.green / 100) +
b * (settings.blue.blue / 100) +
settings.blue.constant * 2.55;
}
resultData[i] = Math.max(0, Math.min(255, newR));
resultData[i + 1] = Math.max(0, Math.min(255, newG));
resultData[i + 2] = Math.max(0, Math.min(255, newB));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,111 @@
export interface ColorBalanceSettings {
shadows: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
midtones: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
highlights: {
cyanRed: number;
magentaGreen: number;
yellowBlue: number;
};
preserveLuminosity: boolean;
}
export const DEFAULT_COLOR_BALANCE: ColorBalanceSettings = {
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
preserveLuminosity: true,
};
function getLuminance(r: number, g: number, b: number): number {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function getToneWeight(luminance: number, tone: 'shadows' | 'midtones' | 'highlights'): number {
const normalized = luminance / 255;
switch (tone) {
case 'shadows':
if (normalized <= 0.25) return 1;
if (normalized <= 0.5) return 1 - (normalized - 0.25) / 0.25;
return 0;
case 'highlights':
if (normalized >= 0.75) return 1;
if (normalized >= 0.5) return (normalized - 0.5) / 0.25;
return 0;
case 'midtones':
if (normalized >= 0.25 && normalized <= 0.75) {
const distFromCenter = Math.abs(normalized - 0.5);
return 1 - distFromCenter / 0.25;
}
return 0;
}
}
export function applyColorBalance(imageData: ImageData, settings: ColorBalanceSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
const a = data[i + 3];
const luminance = getLuminance(r, g, b);
const shadowWeight = getToneWeight(luminance, 'shadows');
const midtoneWeight = getToneWeight(luminance, 'midtones');
const highlightWeight = getToneWeight(luminance, 'highlights');
let rShift = 0, gShift = 0, bShift = 0;
if (shadowWeight > 0) {
rShift += settings.shadows.cyanRed * shadowWeight;
gShift += settings.shadows.magentaGreen * shadowWeight;
bShift += settings.shadows.yellowBlue * shadowWeight;
}
if (midtoneWeight > 0) {
rShift += settings.midtones.cyanRed * midtoneWeight;
gShift += settings.midtones.magentaGreen * midtoneWeight;
bShift += settings.midtones.yellowBlue * midtoneWeight;
}
if (highlightWeight > 0) {
rShift += settings.highlights.cyanRed * highlightWeight;
gShift += settings.highlights.magentaGreen * highlightWeight;
bShift += settings.highlights.yellowBlue * highlightWeight;
}
r = Math.max(0, Math.min(255, r + rShift));
g = Math.max(0, Math.min(255, g + gShift));
b = Math.max(0, Math.min(255, b + bShift));
if (settings.preserveLuminosity) {
const newLuminance = getLuminance(r, g, b);
if (newLuminance > 0) {
const ratio = luminance / newLuminance;
r = Math.max(0, Math.min(255, r * ratio));
g = Math.max(0, Math.min(255, g * ratio));
b = Math.max(0, Math.min(255, b * ratio));
}
}
resultData[i] = r;
resultData[i + 1] = g;
resultData[i + 2] = b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,176 @@
export interface ColorLookupSettings {
lutData: Float32Array | null;
lutSize: number;
strength: number;
}
export const DEFAULT_COLOR_LOOKUP: ColorLookupSettings = {
lutData: null,
lutSize: 0,
strength: 100,
};
export function parseCubeLUT(content: string): { data: Float32Array; size: number } | null {
const lines = content.split('\n');
let size = 0;
const data: number[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#') || trimmed === '') continue;
if (trimmed.startsWith('LUT_3D_SIZE')) {
const match = trimmed.match(/LUT_3D_SIZE\s+(\d+)/);
if (match) {
size = parseInt(match[1], 10);
}
continue;
}
if (trimmed.startsWith('TITLE') || trimmed.startsWith('DOMAIN_')) continue;
const values = trimmed.split(/\s+/).map(parseFloat);
if (values.length === 3 && values.every((v) => !isNaN(v))) {
data.push(...values);
}
}
if (size === 0 || data.length !== size * size * size * 3) {
return null;
}
return { data: new Float32Array(data), size };
}
export function parse3dlLUT(content: string): { data: Float32Array; size: number } | null {
const lines = content.split('\n');
const data: number[] = [];
let size = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === '' || trimmed.startsWith('#')) continue;
const values = trimmed.split(/\s+/).map(parseFloat);
if (values.length === 1 && size === 0) {
size = Math.round(Math.cbrt(values[0]));
continue;
}
if (values.length === 3 && values.every((v) => !isNaN(v))) {
data.push(values[0] / 4095, values[1] / 4095, values[2] / 4095);
}
}
if (size === 0) {
size = Math.round(Math.cbrt(data.length / 3));
}
if (size === 0 || data.length !== size * size * size * 3) {
return null;
}
return { data: new Float32Array(data), size };
}
function trilinearInterpolate(
lutData: Float32Array,
size: number,
r: number,
g: number,
b: number
): { r: number; g: number; b: number } {
const rScaled = r * (size - 1);
const gScaled = g * (size - 1);
const bScaled = b * (size - 1);
const r0 = Math.floor(rScaled);
const g0 = Math.floor(gScaled);
const b0 = Math.floor(bScaled);
const r1 = Math.min(r0 + 1, size - 1);
const g1 = Math.min(g0 + 1, size - 1);
const b1 = Math.min(b0 + 1, size - 1);
const rFrac = rScaled - r0;
const gFrac = gScaled - g0;
const bFrac = bScaled - b0;
const getIndex = (ri: number, gi: number, bi: number) => (bi * size * size + gi * size + ri) * 3;
const c000 = getIndex(r0, g0, b0);
const c100 = getIndex(r1, g0, b0);
const c010 = getIndex(r0, g1, b0);
const c110 = getIndex(r1, g1, b0);
const c001 = getIndex(r0, g0, b1);
const c101 = getIndex(r1, g0, b1);
const c011 = getIndex(r0, g1, b1);
const c111 = getIndex(r1, g1, b1);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const interpolate = (channel: number) => {
const c00 = lerp(lutData[c000 + channel], lutData[c100 + channel], rFrac);
const c01 = lerp(lutData[c001 + channel], lutData[c101 + channel], rFrac);
const c10 = lerp(lutData[c010 + channel], lutData[c110 + channel], rFrac);
const c11 = lerp(lutData[c011 + channel], lutData[c111 + channel], rFrac);
const c0 = lerp(c00, c10, gFrac);
const c1 = lerp(c01, c11, gFrac);
return lerp(c0, c1, bFrac);
};
return {
r: interpolate(0),
g: interpolate(1),
b: interpolate(2),
};
}
export function applyColorLookup(imageData: ImageData, settings: ColorLookupSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
if (!settings.lutData || settings.lutSize === 0) {
resultData.set(data);
return new ImageData(resultData, width, height);
}
const strength = settings.strength / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i] / 255;
const g = data[i + 1] / 255;
const b = data[i + 2] / 255;
const a = data[i + 3];
const lutColor = trilinearInterpolate(settings.lutData, settings.lutSize, r, g, b);
resultData[i] = Math.max(0, Math.min(255, (r + (lutColor.r - r) * strength) * 255));
resultData[i + 1] = Math.max(0, Math.min(255, (g + (lutColor.g - g) * strength) * 255));
resultData[i + 2] = Math.max(0, Math.min(255, (b + (lutColor.b - b) * strength) * 255));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function createIdentityLUT(size: number): Float32Array {
const data = new Float32Array(size * size * size * 3);
for (let b = 0; b < size; b++) {
for (let g = 0; g < size; g++) {
for (let r = 0; r < size; r++) {
const idx = (b * size * size + g * size + r) * 3;
data[idx] = r / (size - 1);
data[idx + 1] = g / (size - 1);
data[idx + 2] = b / (size - 1);
}
}
}
return data;
}

View file

@ -0,0 +1,164 @@
export interface GradientStop {
position: number;
color: string;
}
export interface GradientMapSettings {
stops: GradientStop[];
dither: boolean;
reverse: boolean;
}
export const DEFAULT_GRADIENT_MAP: GradientMapSettings = {
stops: [
{ position: 0, color: '#000000' },
{ position: 100, color: '#ffffff' },
],
dither: false,
reverse: false,
};
export const GRADIENT_MAP_PRESETS = {
blackWhite: [
{ position: 0, color: '#000000' },
{ position: 100, color: '#ffffff' },
],
sepiaTone: [
{ position: 0, color: '#1a0f00' },
{ position: 50, color: '#8b6914' },
{ position: 100, color: '#ffe7b3' },
],
duotoneBlueOrange: [
{ position: 0, color: '#001f4d' },
{ position: 100, color: '#ff8c00' },
],
duotonePurpleTeal: [
{ position: 0, color: '#2d1b4e' },
{ position: 100, color: '#00d4aa' },
],
sunset: [
{ position: 0, color: '#1a0533' },
{ position: 33, color: '#6b1839' },
{ position: 66, color: '#d44d1b' },
{ position: 100, color: '#ffd700' },
],
coolBlue: [
{ position: 0, color: '#000033' },
{ position: 50, color: '#0066cc' },
{ position: 100, color: '#99ccff' },
],
warmRed: [
{ position: 0, color: '#1a0000' },
{ position: 50, color: '#cc3300' },
{ position: 100, color: '#ffcc99' },
],
greenForest: [
{ position: 0, color: '#001a00' },
{ position: 50, color: '#336600' },
{ position: 100, color: '#99cc66' },
],
infrared: [
{ position: 0, color: '#000000' },
{ position: 25, color: '#330066' },
{ position: 50, color: '#ff0066' },
{ position: 75, color: '#ffcc00' },
{ position: 100, color: '#ffffff' },
],
thermal: [
{ position: 0, color: '#000033' },
{ position: 25, color: '#6600cc' },
{ position: 50, color: '#ff0000' },
{ position: 75, color: '#ffff00' },
{ position: 100, color: '#ffffff' },
],
};
function parseColor(color: string): { r: number; g: number; b: number } {
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (match) {
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
};
}
return { r: 0, g: 0, b: 0 };
}
function interpolateGradient(
stops: GradientStop[],
position: number
): { r: number; g: number; b: number } {
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
if (stops.length === 1) return parseColor(stops[0].color);
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
if (position <= sortedStops[0].position) {
return parseColor(sortedStops[0].color);
}
if (position >= sortedStops[sortedStops.length - 1].position) {
return parseColor(sortedStops[sortedStops.length - 1].color);
}
for (let i = 0; i < sortedStops.length - 1; i++) {
const stop1 = sortedStops[i];
const stop2 = sortedStops[i + 1];
if (position >= stop1.position && position <= stop2.position) {
const t = (position - stop1.position) / (stop2.position - stop1.position);
const c1 = parseColor(stop1.color);
const c2 = parseColor(stop2.color);
return {
r: Math.round(c1.r + (c2.r - c1.r) * t),
g: Math.round(c1.g + (c2.g - c1.g) * t),
b: Math.round(c1.b + (c2.b - c1.b) * t),
};
}
}
return parseColor(sortedStops[sortedStops.length - 1].color);
}
function getLuminance(r: number, g: number, b: number): number {
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
}
export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const lookupTable: Array<{ r: number; g: number; b: number }> = [];
for (let i = 0; i < 256; i++) {
let position = (i / 255) * 100;
if (settings.reverse) {
position = 100 - position;
}
lookupTable[i] = interpolateGradient(settings.stops, position);
}
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let luminance = getLuminance(r, g, b);
if (settings.dither) {
const noise = (Math.random() - 0.5) * (1 / 255);
luminance = Math.max(0, Math.min(1, luminance + noise));
}
const idx = Math.round(luminance * 255);
const mappedColor = lookupTable[idx];
resultData[i] = mappedColor.r;
resultData[i + 1] = mappedColor.g;
resultData[i + 2] = mappedColor.b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,305 @@
export interface HistogramData {
red: Uint32Array;
green: Uint32Array;
blue: Uint32Array;
luminosity: Uint32Array;
}
export interface HistogramStatistics {
mean: number;
stdDev: number;
median: number;
min: number;
max: number;
pixelCount: number;
shadowsClipped: number;
highlightsClipped: number;
}
export interface HistogramResult {
data: HistogramData;
statistics: {
red: HistogramStatistics;
green: HistogramStatistics;
blue: HistogramStatistics;
luminosity: HistogramStatistics;
};
}
export interface ColorInfo {
rgb: { r: number; g: number; b: number };
hsb: { h: number; s: number; b: number };
hsl: { h: number; s: number; l: number };
lab: { l: number; a: number; b: number };
cmyk: { c: number; m: number; y: number; k: number };
hex: string;
}
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
let sum = 0;
let min = 255;
let max = 0;
let pixelCount = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
sum += i * count;
pixelCount += count;
if (i < min) min = i;
if (i > max) max = i;
}
}
const mean = pixelCount > 0 ? sum / pixelCount : 0;
let varianceSum = 0;
for (let i = 0; i < 256; i++) {
const count = histogram[i];
if (count > 0) {
varianceSum += count * Math.pow(i - mean, 2);
}
}
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
let medianCount = 0;
let median = 0;
const halfCount = pixelCount / 2;
for (let i = 0; i < 256; i++) {
medianCount += histogram[i];
if (medianCount >= halfCount) {
median = i;
break;
}
}
const shadowsClipped = (histogram[0] / totalPixels) * 100;
const highlightsClipped = (histogram[255] / totalPixels) * 100;
return {
mean,
stdDev,
median,
min: pixelCount > 0 ? min : 0,
max: pixelCount > 0 ? max : 0,
pixelCount,
shadowsClipped,
highlightsClipped,
};
}
export function calculateHistogram(imageData: ImageData): HistogramResult {
const { data } = imageData;
const histogramData: HistogramData = {
red: new Uint32Array(256),
green: new Uint32Array(256),
blue: new Uint32Array(256),
luminosity: new Uint32Array(256),
};
const totalPixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
histogramData.red[r]++;
histogramData.green[g]++;
histogramData.blue[b]++;
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
histogramData.luminosity[luminosity]++;
}
return {
data: histogramData,
statistics: {
red: calculateStatistics(histogramData.red, totalPixels),
green: calculateStatistics(histogramData.green, totalPixels),
blue: calculateStatistics(histogramData.blue, totalPixels),
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
},
};
}
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
const delta = max - min;
let h = 0;
if (delta !== 0) {
if (max === rNorm) {
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
} else if (max === gNorm) {
h = ((bNorm - rNorm) / delta + 2) / 6;
} else {
h = ((rNorm - gNorm) / delta + 4) / 6;
}
}
const l = (max + min) / 2;
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
const sBrightness = max === 0 ? 0 : delta / max;
const k = 1 - max;
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
const labL = 116 * f(yVal) - 16;
const labA = 500 * (f(x) - f(yVal));
const labB = 200 * (f(yVal) - f(z));
const hex = '#' +
r.toString(16).padStart(2, '0') +
g.toString(16).padStart(2, '0') +
b.toString(16).padStart(2, '0');
return {
rgb: { r, g, b },
hsb: {
h: Math.round(h * 360),
s: Math.round(sBrightness * 100),
b: Math.round(max * 100),
},
hsl: {
h: Math.round(h * 360),
s: Math.round(sHsl * 100),
l: Math.round(l * 100),
},
lab: {
l: Math.round(labL),
a: Math.round(labA),
b: Math.round(labB),
},
cmyk: {
c: Math.round(c * 100),
m: Math.round(m * 100),
y: Math.round(y * 100),
k: Math.round(k * 100),
},
hex,
};
}
export function renderHistogram(
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
histogram: Uint32Array,
color: string,
width: number,
height: number,
logarithmic: boolean = false
): void {
const maxValue = Math.max(...histogram);
if (maxValue === 0) return;
ctx.fillStyle = color;
ctx.globalAlpha = 0.7;
const barWidth = width / 256;
for (let i = 0; i < 256; i++) {
let normalizedValue: number;
if (logarithmic && histogram[i] > 0) {
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
} else {
normalizedValue = histogram[i] / maxValue;
}
const barHeight = normalizedValue * height;
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
}
ctx.globalAlpha = 1;
}
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const histogram = calculateHistogram(imageData);
const totalPixels = data.length / 4;
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
let count = 0;
if (fromStart) {
for (let i = 0; i < 256; i++) {
count += hist[i];
if (count > clipPixels) return i;
}
return 0;
} else {
for (let i = 255; i >= 0; i--) {
count += hist[i];
if (count > clipPixels) return i;
}
return 255;
}
};
const channels = ['red', 'green', 'blue'] as const;
const adjustments = channels.map((channel) => {
const hist = histogram.data[channel];
const inputBlack = findClipPoint(hist, true);
const inputWhite = findClipPoint(hist, false);
return { inputBlack, inputWhite };
});
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const { inputBlack, inputWhite } = adjustments[c];
const range = inputWhite - inputBlack || 1;
const value = data[i + c];
const adjusted = ((value - inputBlack) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
export function autoContrast(imageData: ImageData): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
let minLum = 255;
let maxLum = 0;
for (let i = 0; i < data.length; i += 4) {
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
if (lum < minLum) minLum = lum;
if (lum > maxLum) maxLum = lum;
}
const range = maxLum - minLum || 1;
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const adjusted = ((data[i + c] - minLum) / range) * 255;
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,117 @@
export type PhotoFilterPreset =
| 'warming-85'
| 'warming-81'
| 'warming-lba'
| 'cooling-80'
| 'cooling-82'
| 'cooling-lbb'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'cyan'
| 'blue'
| 'violet'
| 'magenta'
| 'sepia'
| 'deep-red'
| 'deep-blue'
| 'deep-emerald'
| 'deep-yellow'
| 'underwater'
| 'custom';
export interface PhotoFilterSettings {
filter: PhotoFilterPreset;
color: string;
density: number;
preserveLuminosity: boolean;
}
export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = {
filter: 'warming-85',
color: '#ec8a00',
density: 25,
preserveLuminosity: true,
};
export const PHOTO_FILTER_COLORS: Record<PhotoFilterPreset, string> = {
'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);
}

View file

@ -0,0 +1,108 @@
export interface PosterizeSettings {
levels: number;
}
export interface ThresholdSettings {
level: number;
}
export const DEFAULT_POSTERIZE: PosterizeSettings = {
levels: 4,
};
export const DEFAULT_THRESHOLD: ThresholdSettings = {
level: 128,
};
export function applyPosterize(imageData: ImageData, settings: PosterizeSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const levels = Math.max(2, Math.min(255, Math.round(settings.levels)));
const step = 255 / (levels - 1);
const divisor = 256 / levels;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
resultData[i] = Math.round(Math.floor(r / divisor) * step);
resultData[i + 1] = Math.round(Math.floor(g / divisor) * step);
resultData[i + 2] = Math.round(Math.floor(b / divisor) * step);
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function applyThreshold(imageData: ImageData, settings: ThresholdSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const level = Math.max(0, Math.min(255, settings.level));
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
const value = luminance >= level ? 255 : 0;
resultData[i] = value;
resultData[i + 1] = value;
resultData[i + 2] = value;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}
export function applyAdaptiveThreshold(
imageData: ImageData,
blockSize: number = 11,
constant: number = 2
): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const grayData = new Uint8Array(width * height);
for (let i = 0; i < data.length; i += 4) {
const idx = i / 4;
grayData[idx] = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
}
const halfBlock = Math.floor(blockSize / 2);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0;
let count = 0;
for (let by = -halfBlock; by <= halfBlock; by++) {
for (let bx = -halfBlock; bx <= halfBlock; bx++) {
const nx = Math.min(Math.max(x + bx, 0), width - 1);
const ny = Math.min(Math.max(y + by, 0), height - 1);
sum += grayData[ny * width + nx];
count++;
}
}
const mean = sum / count;
const threshold = mean - constant;
const pixelIdx = y * width + x;
const value = grayData[pixelIdx] > threshold ? 255 : 0;
const i = pixelIdx * 4;
resultData[i] = value;
resultData[i + 1] = value;
resultData[i + 2] = value;
resultData[i + 3] = data[i + 3];
}
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,225 @@
export type SelectiveColorRange =
| 'reds'
| 'yellows'
| 'greens'
| 'cyans'
| 'blues'
| 'magentas'
| 'whites'
| 'neutrals'
| 'blacks';
export interface SelectiveColorAdjustment {
cyan: number;
magenta: number;
yellow: number;
black: number;
}
export interface SelectiveColorSettings {
reds: SelectiveColorAdjustment;
yellows: SelectiveColorAdjustment;
greens: SelectiveColorAdjustment;
cyans: SelectiveColorAdjustment;
blues: SelectiveColorAdjustment;
magentas: SelectiveColorAdjustment;
whites: SelectiveColorAdjustment;
neutrals: SelectiveColorAdjustment;
blacks: SelectiveColorAdjustment;
method: 'relative' | 'absolute';
}
const DEFAULT_ADJUSTMENT: SelectiveColorAdjustment = {
cyan: 0,
magenta: 0,
yellow: 0,
black: 0,
};
export const DEFAULT_SELECTIVE_COLOR: SelectiveColorSettings = {
reds: { ...DEFAULT_ADJUSTMENT },
yellows: { ...DEFAULT_ADJUSTMENT },
greens: { ...DEFAULT_ADJUSTMENT },
cyans: { ...DEFAULT_ADJUSTMENT },
blues: { ...DEFAULT_ADJUSTMENT },
magentas: { ...DEFAULT_ADJUSTMENT },
whites: { ...DEFAULT_ADJUSTMENT },
neutrals: { ...DEFAULT_ADJUSTMENT },
blacks: { ...DEFAULT_ADJUSTMENT },
method: 'relative',
};
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) {
return { h: 0, s: 0, l };
}
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return { h, s, l };
}
function getColorRangeWeight(r: number, g: number, b: number, range: SelectiveColorRange): number {
const { h, s, l } = rgbToHsl(r, g, b);
const hue = h * 360;
switch (range) {
case 'reds':
if (s < 0.1) return 0;
if ((hue >= 345 || hue <= 15)) return s;
if (hue > 15 && hue <= 45) return s * (1 - (hue - 15) / 30);
if (hue >= 315 && hue < 345) return s * ((hue - 315) / 30);
return 0;
case 'yellows':
if (s < 0.1) return 0;
if (hue >= 45 && hue <= 75) return s;
if (hue > 15 && hue < 45) return s * ((hue - 15) / 30);
if (hue > 75 && hue <= 105) return s * (1 - (hue - 75) / 30);
return 0;
case 'greens':
if (s < 0.1) return 0;
if (hue >= 105 && hue <= 135) return s;
if (hue > 75 && hue < 105) return s * ((hue - 75) / 30);
if (hue > 135 && hue <= 165) return s * (1 - (hue - 135) / 30);
return 0;
case 'cyans':
if (s < 0.1) return 0;
if (hue >= 165 && hue <= 195) return s;
if (hue > 135 && hue < 165) return s * ((hue - 135) / 30);
if (hue > 195 && hue <= 225) return s * (1 - (hue - 195) / 30);
return 0;
case 'blues':
if (s < 0.1) return 0;
if (hue >= 225 && hue <= 255) return s;
if (hue > 195 && hue < 225) return s * ((hue - 195) / 30);
if (hue > 255 && hue <= 285) return s * (1 - (hue - 255) / 30);
return 0;
case 'magentas':
if (s < 0.1) return 0;
if (hue >= 285 && hue <= 315) return s;
if (hue > 255 && hue < 285) return s * ((hue - 255) / 30);
if (hue > 315 && hue <= 345) return s * (1 - (hue - 315) / 30);
return 0;
case 'whites':
if (l >= 0.8) return (l - 0.8) / 0.2;
return 0;
case 'blacks':
if (l <= 0.2) return (0.2 - l) / 0.2;
return 0;
case 'neutrals':
if (s < 0.2 && l > 0.2 && l < 0.8) {
return (0.2 - s) / 0.2 * Math.min((l - 0.2) / 0.3, (0.8 - l) / 0.3, 1);
}
return 0;
}
}
function rgbToCmyk(r: number, g: number, b: number): { c: number; m: number; y: number; k: number } {
r /= 255;
g /= 255;
b /= 255;
const k = 1 - Math.max(r, g, b);
if (k === 1) {
return { c: 0, m: 0, y: 0, k: 1 };
}
const c = (1 - r - k) / (1 - k);
const m = (1 - g - k) / (1 - k);
const y = (1 - b - k) / (1 - k);
return { c, m, y, k };
}
function cmykToRgb(c: number, m: number, y: number, k: number): { r: number; g: number; b: number } {
const r = 255 * (1 - c) * (1 - k);
const g = 255 * (1 - m) * (1 - k);
const b = 255 * (1 - y) * (1 - k);
return {
r: Math.max(0, Math.min(255, Math.round(r))),
g: Math.max(0, Math.min(255, Math.round(g))),
b: Math.max(0, Math.min(255, Math.round(b))),
};
}
export function applySelectiveColor(imageData: ImageData, settings: SelectiveColorSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const ranges: SelectiveColorRange[] = [
'reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'whites', 'neutrals', 'blacks'
];
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
let { c, m, y, k } = rgbToCmyk(r, g, b);
for (const range of ranges) {
const weight = getColorRangeWeight(r, g, b, range);
if (weight <= 0) continue;
const adj = settings[range];
if (settings.method === 'relative') {
c = c + (adj.cyan / 100) * c * weight;
m = m + (adj.magenta / 100) * m * weight;
y = y + (adj.yellow / 100) * y * weight;
k = k + (adj.black / 100) * k * weight;
} else {
c = c + (adj.cyan / 100) * weight;
m = m + (adj.magenta / 100) * weight;
y = y + (adj.yellow / 100) * weight;
k = k + (adj.black / 100) * weight;
}
}
c = Math.max(0, Math.min(1, c));
m = Math.max(0, Math.min(1, m));
y = Math.max(0, Math.min(1, y));
k = Math.max(0, Math.min(1, k));
const rgb = cmykToRgb(c, m, y, k);
resultData[i] = rgb.r;
resultData[i + 1] = rgb.g;
resultData[i + 2] = rgb.b;
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { parseProject } from './services/project-schema';
import { migrateProject, CURRENT_VERSION } from './services/project-migration';
// ── App smoke tests ──────────────────────────────────────────────────────────
//
// These tests exercise the integration seam between the project schema,
// migration utilities, and the store to confirm the whole pipeline is wired up
// and importing correctly.
describe('OpenReel Image baseline smoke tests', () => {
// Schema is importable.
it('project schema module is importable', () => {
expect(typeof parseProject).toBe('function');
});
// Migration is importable and exposes the current version constant.
it('migration module exposes CURRENT_VERSION', () => {
expect(typeof CURRENT_VERSION).toBe('number');
expect(CURRENT_VERSION).toBeGreaterThanOrEqual(1);
});
// A minimal valid project document passes schema validation.
it('validates a minimal valid project', () => {
const baseLayer = {
id: 'l1',
name: 'Layer',
type: 'text' as const,
visible: true,
locked: false,
transform: {
x: 0, y: 0, width: 200, height: 50, rotation: 0,
scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, opacity: 1,
},
blendMode: { mode: 'normal' as const },
shadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 0, offsetY: 4 },
innerShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 },
stroke: { enabled: false, color: '#000000', width: 1, style: 'solid' as const },
glow: { enabled: false, color: '#ffffff', blur: 20, intensity: 1 },
filters: {
brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0,
vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0,
blurType: 'gaussian' as const, blurAngle: 0, sharpen: 0, vignette: 0,
grain: 0, sepia: 0, invert: 0,
},
parentId: null,
flipHorizontal: false,
flipVertical: false,
mask: null,
clippingMask: false,
levels: {
enabled: false,
master: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
red: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
green: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
blue: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
},
curves: {
enabled: false,
master: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
red: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
green: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
blue: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
},
colorBalance: {
enabled: false,
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
preserveLuminosity: true,
},
selectiveColor: {
enabled: false, method: 'relative' as const,
reds: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
yellows: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
greens: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
cyans: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
blues: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
magentas: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
whites: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
neutrals: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
blacks: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
},
blackWhite: {
enabled: false, reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20,
magentas: 80, tintEnabled: false, tintHue: 35, tintSaturation: 25,
},
photoFilter: {
enabled: false, filter: 'warming-85' as const, color: '#ec8a00',
density: 25, preserveLuminosity: true,
},
channelMixer: {
enabled: false, monochrome: false,
red: { red: 100, green: 0, blue: 0, constant: 0 },
green: { red: 0, green: 100, blue: 0, constant: 0 },
blue: { red: 0, green: 0, blue: 100, constant: 0 },
},
gradientMap: {
enabled: false,
stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }],
reverse: false, dither: false,
},
posterize: { enabled: false, levels: 4 },
threshold: { enabled: false, level: 128 },
content: 'Hello',
style: {
fontFamily: 'Inter', fontSize: 24, fontWeight: 400,
fontStyle: 'normal' as const, textDecoration: 'none' as const,
textAlign: 'left' as const, verticalAlign: 'top' as const,
lineHeight: 1.4, letterSpacing: 0, fillType: 'solid' as const,
color: '#ffffff', gradient: null, strokeColor: null, strokeWidth: 0,
backgroundColor: null, backgroundPadding: 8, backgroundRadius: 4,
textShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 0, offsetY: 2 },
},
autoSize: true,
};
const validProject = {
id: 'p1',
name: 'Smoke Test',
createdAt: Date.now(),
updatedAt: Date.now(),
version: 1,
artboards: [
{
id: 'ab1',
name: 'Artboard 1',
size: { width: 1080, height: 1080 },
background: { type: 'color', color: '#ffffff' },
layerIds: ['l1'],
position: { x: 0, y: 0 },
},
],
layers: { l1: baseLayer },
assets: {},
activeArtboardId: 'ab1',
};
const result = parseProject(validProject);
expect(result.success).toBe(true);
});
// An invalid document is rejected.
it('rejects an invalid project document', () => {
const result = parseProject({ id: 42, broken: true });
expect(result.success).toBe(false);
});
// Migration promotes a v0 document to v1.
it('migrates a v0 project to v1', () => {
const v0 = {
id: 'old',
name: 'Legacy',
createdAt: 0,
updatedAt: 0,
artboards: [{ id: 'ab-old', name: 'Page 1' }],
layers: {},
assets: {},
};
const migrated = migrateProject(v0 as Record<string, unknown>);
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<string, unknown>);
expect(migrated.version).toBe(1);
});
});

View file

@ -0,0 +1,107 @@
import { useState, lazy, Suspense } from 'react';
import { Toolbar } from './toolbar/Toolbar';
import { LeftPanel } from './panels/LeftPanel';
import { Canvas } from './canvas/Canvas';
import { Inspector } from './inspector/Inspector';
import { LayerPanel } from './layers/LayerPanel';
import { HistoryPanel } from './panels/HistoryPanel';
import { GuidePanel } from './panels/GuidePanel';
import { PagesBar } from './pages/PagesBar';
import { useUIStore } from '../../stores/ui-store';
import { useProjectStore } from '../../stores/project-store';
import { Layers, History, Ruler } from 'lucide-react';
const ExportDialog = lazy(() => import('./ExportDialog').then(m => ({ default: m.ExportDialog })));
type BottomTab = 'layers' | 'history' | 'guides';
export function EditorInterface() {
const { isPanelCollapsed, isInspectorCollapsed, isExportDialogOpen, closeExportDialog } = useUIStore();
const { project } = useProjectStore();
const [bottomTab, setBottomTab] = useState<BottomTab>('layers');
if (!project) {
return (
<div className="h-full w-full flex items-center justify-center bg-background">
<p className="text-muted-foreground">No project loaded</p>
</div>
);
}
return (
<div className="h-full w-full flex flex-col bg-background overflow-hidden">
<Toolbar />
<div className="flex-1 flex overflow-hidden">
{!isPanelCollapsed && (
<div className="w-72 border-r border-border flex flex-col bg-card">
<LeftPanel />
</div>
)}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex overflow-hidden">
<Canvas />
</div>
<PagesBar />
</div>
{!isInspectorCollapsed && (
<div className="w-72 border-l border-border flex flex-col bg-card">
<div className="flex-1 overflow-y-auto">
<Inspector />
</div>
<div className="h-64 border-t border-border flex flex-col">
<div className="flex border-b border-border">
<button
onClick={() => setBottomTab('layers')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'layers'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Layers size={14} />
Layers
</button>
<button
onClick={() => setBottomTab('guides')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'guides'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<Ruler size={14} />
Guides
</button>
<button
onClick={() => setBottomTab('history')}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors ${
bottomTab === 'history'
? 'text-foreground bg-background border-b-2 border-primary -mb-px'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
<History size={14} />
History
</button>
</div>
<div className="flex-1 overflow-hidden">
{bottomTab === 'layers' && <LayerPanel />}
{bottomTab === 'guides' && <GuidePanel />}
{bottomTab === 'history' && <HistoryPanel />}
</div>
</div>
</div>
)}
</div>
{isExportDialogOpen && (
<Suspense fallback={null}>
<ExportDialog open={isExportDialogOpen} onClose={closeExportDialog} />
</Suspense>
)}
</div>
);
}

View file

@ -0,0 +1,626 @@
import { useState, useMemo, useEffect } from 'react';
import { Download, FileImage, Loader2, Link2, Link2Off, Printer, Instagram, Youtube, Twitter, Linkedin, Facebook, Image } from 'lucide-react';
import { Dialog, DialogFooter } from '../ui/Dialog';
import { useProjectStore } from '../../stores/project-store';
import { useUIStore } from '../../stores/ui-store';
import {
exportProject,
downloadBlob,
getExportFilename,
type ExportFormat,
type ExportQuality,
type ExportOptions,
} from '../../services/export-service';
interface ExportDialogProps {
open: boolean;
onClose: () => void;
}
type FormatInfo = {
id: ExportFormat;
name: string;
description: string;
supportsTransparency: boolean;
supportsQuality: boolean;
};
const FORMATS: FormatInfo[] = [
{ id: 'png', name: 'PNG', description: 'Lossless, best for graphics', supportsTransparency: true, supportsQuality: false },
{ id: 'jpg', name: 'JPG', description: 'Smaller size, photos', supportsTransparency: false, supportsQuality: true },
{ id: 'webp', name: 'WebP', description: 'Modern, best compression', supportsTransparency: true, supportsQuality: true },
];
const QUALITY_PRESETS: { id: ExportQuality; name: string; value: number }[] = [
{ id: 'low', name: 'Low', value: 60 },
{ id: 'medium', name: 'Medium', value: 80 },
{ id: 'high', name: 'High', value: 92 },
{ id: 'max', name: 'Maximum', value: 100 },
];
const SCALE_OPTIONS = [
{ value: 0.5, label: '0.5x' },
{ value: 1, label: '1x' },
{ value: 2, label: '2x' },
{ value: 3, label: '3x' },
{ value: 4, label: '4x' },
];
const DPI_OPTIONS = [
{ value: 72, label: '72 DPI', description: 'Screen' },
{ value: 150, label: '150 DPI', description: 'Web print' },
{ value: 300, label: '300 DPI', description: 'Print' },
{ value: 600, label: '600 DPI', description: 'High quality' },
];
type PlatformPreset = {
id: string;
name: string;
icon: React.ElementType;
format: ExportFormat;
quality: ExportQuality;
maxFileSize?: string;
recommendedSize?: { width: number; height: number };
description: string;
};
const PLATFORM_PRESETS: PlatformPreset[] = [
{
id: 'instagram-post',
name: 'Instagram Post',
icon: Instagram,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1080, height: 1080 },
description: 'Square post, max 30MB',
},
{
id: 'instagram-story',
name: 'Instagram Story',
icon: Instagram,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1080, height: 1920 },
description: '9:16 vertical',
},
{
id: 'youtube-thumbnail',
name: 'YouTube Thumbnail',
icon: Youtube,
format: 'jpg',
quality: 'high',
maxFileSize: '2MB',
recommendedSize: { width: 1280, height: 720 },
description: '16:9, under 2MB',
},
{
id: 'twitter-post',
name: 'Twitter/X Post',
icon: Twitter,
format: 'png',
quality: 'high',
recommendedSize: { width: 1200, height: 675 },
description: '16:9 landscape',
},
{
id: 'facebook-post',
name: 'Facebook Post',
icon: Facebook,
format: 'jpg',
quality: 'high',
recommendedSize: { width: 1200, height: 630 },
description: '1.91:1 ratio',
},
{
id: 'linkedin-post',
name: 'LinkedIn Post',
icon: Linkedin,
format: 'png',
quality: 'high',
recommendedSize: { width: 1200, height: 627 },
description: 'Professional feed',
},
{
id: 'web-optimized',
name: 'Web Optimized',
icon: Image,
format: 'webp',
quality: 'medium',
description: 'Smallest file size',
},
{
id: 'print-ready',
name: 'Print Ready',
icon: Printer,
format: 'png',
quality: 'max',
description: 'Highest quality PNG',
},
];
type SizeMode = 'scale' | 'custom' | 'dpi';
export function ExportDialog({ open, onClose }: ExportDialogProps) {
const { project, selectedArtboardId } = useProjectStore();
const { showNotification } = useUIStore();
const [format, setFormat] = useState<ExportFormat>('png');
const [quality, setQuality] = useState<ExportQuality>('high');
const [scale, setScale] = useState(1);
const [sizeMode, setSizeMode] = useState<SizeMode>('scale');
const [selectedPreset, setSelectedPreset] = useState<string | null>(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 (
<Dialog
open={open}
onClose={onClose}
title="Export Image"
description="Choose format and quality settings"
maxWidth="md"
>
<div className="space-y-6">
<div>
<div className="flex items-center justify-between mb-3">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Quick Presets
</label>
{selectedPreset && (
<button
onClick={clearPreset}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
<div className="grid grid-cols-4 gap-2">
{PLATFORM_PRESETS.map((preset) => {
const Icon = preset.icon;
const isSelected = selectedPreset === preset.id;
return (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset)}
className={`p-2 rounded-lg border text-center transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<Icon size={16} className={`mx-auto mb-1 ${isSelected ? 'text-primary' : 'text-muted-foreground'}`} />
<span className="block text-[10px] font-medium truncate">{preset.name}</span>
<span className="block text-[8px] text-muted-foreground truncate">{preset.description}</span>
</button>
);
})}
</div>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Format
</label>
<div className="grid grid-cols-3 gap-2">
{FORMATS.map((f) => (
<button
key={f.id}
onClick={() => setFormat(f.id)}
className={`p-3 rounded-lg border text-left transition-all ${
format === f.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<FileImage size={16} className={format === f.id ? 'text-primary' : 'text-muted-foreground'} />
<span className="font-medium text-sm">{f.name}</span>
</div>
<p className="text-[11px] text-muted-foreground">{f.description}</p>
</button>
))}
</div>
</div>
{currentFormat.supportsQuality && (
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Quality
</label>
<div className="grid grid-cols-4 gap-2">
{QUALITY_PRESETS.map((q) => (
<button
key={q.id}
onClick={() => setQuality(q.id)}
className={`px-3 py-2 rounded-lg border text-center transition-all ${
quality === q.id
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<span className="text-sm font-medium">{q.name}</span>
<span className="block text-[10px] text-muted-foreground">{q.value}%</span>
</button>
))}
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Size
</label>
<div className="flex gap-2 mb-3">
<button
onClick={() => setSizeMode('scale')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
sizeMode === 'scale'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Scale
</button>
<button
onClick={() => setSizeMode('custom')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
sizeMode === 'custom'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Custom
</button>
<button
onClick={() => setSizeMode('dpi')}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all flex items-center justify-center gap-1.5 ${
sizeMode === 'dpi'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<Printer size={14} />
Print
</button>
</div>
{sizeMode === 'scale' && (
<div className="flex gap-2">
{SCALE_OPTIONS.map((s) => (
<button
key={s.value}
onClick={() => setScale(s.value)}
className={`flex-1 px-3 py-2 rounded-lg border text-center text-sm font-medium transition-all ${
scale === s.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
{s.label}
</button>
))}
</div>
)}
{sizeMode === 'custom' && (
<div className="flex items-center gap-2">
<div className="flex-1">
<label className="block text-[10px] text-muted-foreground mb-1">Width (px)</label>
<input
type="number"
value={customWidth}
onChange={(e) => 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}
/>
</div>
<button
onClick={() => setLockAspectRatio(!lockAspectRatio)}
className={`mt-5 p-2 rounded-lg transition-colors ${
lockAspectRatio ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
title={lockAspectRatio ? 'Unlock aspect ratio' : 'Lock aspect ratio'}
>
{lockAspectRatio ? <Link2 size={16} /> : <Link2Off size={16} />}
</button>
<div className="flex-1">
<label className="block text-[10px] text-muted-foreground mb-1">Height (px)</label>
<input
type="number"
value={customHeight}
onChange={(e) => 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}
/>
</div>
</div>
)}
{sizeMode === 'dpi' && (
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
{DPI_OPTIONS.map((d) => (
<button
key={d.value}
onClick={() => setDpi(d.value)}
className={`px-2 py-2 rounded-lg border text-center transition-all ${
dpi === d.value
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
<span className="block text-sm font-medium">{d.value}</span>
<span className="block text-[9px] text-muted-foreground">{d.description}</span>
</button>
))}
</div>
{printDimensions && (
<div className="p-3 bg-secondary/30 rounded-lg text-xs text-muted-foreground">
<p>Print size at {dpi} DPI:</p>
<p className="font-medium text-foreground mt-1">
{printDimensions.inches.width}" × {printDimensions.inches.height}" ({printDimensions.cm.width} × {printDimensions.cm.height} cm)
</p>
</div>
)}
</div>
)}
</div>
{currentFormat.supportsTransparency && (
<div>
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
Background
</label>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => setBackground('include')}
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
background === 'include'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Include Background
</button>
<button
onClick={() => setBackground('transparent')}
className={`px-3 py-2.5 rounded-lg border text-sm font-medium transition-all ${
background === 'transparent'
? 'border-primary bg-primary/5 ring-1 ring-primary'
: 'border-border hover:border-muted-foreground/50 hover:bg-secondary/50'
}`}
>
Transparent
</button>
</div>
</div>
)}
{project.artboards.length > 1 && (
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={exportAll}
onChange={(e) => setExportAll(e.target.checked)}
className="w-4 h-4 rounded border-border bg-background text-primary focus:ring-primary/50"
/>
<span className="text-sm">Export all artboards ({project.artboards.length})</span>
</label>
</div>
)}
<div className="p-4 bg-secondary/50 rounded-lg space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Dimensions</span>
<span className="font-medium">
{dimensions?.width} × {dimensions?.height} px
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Estimated size</span>
<span className="font-medium">{estimatedSize}</span>
</div>
</div>
{isExporting && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{progressMessage}</span>
<span className="font-medium">{Math.round(progress)}%</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
<DialogFooter>
<button
onClick={onClose}
disabled={isExporting}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{isExporting ? (
<>
<Loader2 size={16} className="animate-spin" />
Exporting...
</>
) : (
<>
<Download size={16} />
Export
</>
)}
</button>
</DialogFooter>
</Dialog>
);
}

View file

@ -0,0 +1,140 @@
import { X, Keyboard } from 'lucide-react';
interface ShortcutItem {
keys: string[];
description: string;
}
interface ShortcutGroup {
title: string;
shortcuts: ShortcutItem[];
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'Tools',
shortcuts: [
{ keys: ['V'], description: 'Select tool' },
{ keys: ['H'], description: 'Hand/Pan tool' },
{ keys: ['T'], description: 'Text tool' },
{ keys: ['S'], description: 'Shape tool' },
{ keys: ['P'], description: 'Pen tool' },
{ keys: ['I'], description: 'Eyedropper' },
{ keys: ['Z'], description: 'Zoom tool' },
],
},
{
title: 'Edit',
shortcuts: [
{ keys: ['⌘', 'Z'], description: 'Undo' },
{ keys: ['⌘', '⇧', 'Z'], description: 'Redo' },
{ keys: ['⌘', 'C'], description: 'Copy' },
{ keys: ['⌘', 'X'], description: 'Cut' },
{ keys: ['⌘', 'V'], description: 'Paste' },
{ keys: ['⌘', 'D'], description: 'Duplicate' },
{ keys: ['Delete'], description: 'Delete selected' },
],
},
{
title: 'Selection',
shortcuts: [
{ keys: ['⌘', 'A'], description: 'Select all' },
{ keys: ['Esc'], description: 'Deselect all' },
{ keys: ['⌘', 'G'], description: 'Group layers' },
{ keys: ['⌘', '⇧', 'G'], description: 'Ungroup layers' },
],
},
{
title: 'Layer Order',
shortcuts: [
{ keys: ['⌘', ']'], description: 'Bring forward' },
{ keys: ['⌘', '['], description: 'Send backward' },
{ keys: ['⌘', '⇧', ']'], description: 'Bring to front' },
{ keys: ['⌘', '⇧', '['], description: 'Send to back' },
],
},
{
title: 'View',
shortcuts: [
{ keys: ['⌘', '+'], description: 'Zoom in' },
{ keys: ['⌘', '-'], description: 'Zoom out' },
{ keys: ['⌘', '0'], description: 'Zoom to fit' },
{ keys: ["⌘", "'"], description: 'Toggle grid' },
{ keys: ['⌘', ';'], description: 'Toggle guides' },
],
},
{
title: 'Other',
shortcuts: [
{ keys: ['?'], description: 'Show shortcuts' },
{ keys: ['⌘', ','], description: 'Settings' },
],
},
];
interface Props {
isOpen: boolean;
onClose: () => void;
}
export function KeyboardShortcutsPanel({ isOpen, onClose }: Props) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Keyboard size={20} className="text-primary" />
<h2 className="text-lg font-semibold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(80vh-80px)]">
<div className="grid grid-cols-2 gap-6">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title} className="space-y-3">
<h3 className="text-sm font-medium text-foreground">{group.title}</h3>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-accent/50 transition-colors"
>
<span className="text-sm text-muted-foreground">
{shortcut.description}
</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<kbd
key={keyIndex}
className="min-w-[24px] h-6 px-1.5 flex items-center justify-center text-[11px] font-medium bg-secondary border border-border rounded shadow-sm"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
<div className="mt-6 pt-4 border-t border-border">
<p className="text-xs text-muted-foreground text-center">
Press <kbd className="px-1.5 py-0.5 bg-secondary border border-border rounded text-[10px]">?</kbd> to toggle this panel
</p>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,217 @@
import { useState } from 'react';
import { X, Settings, Grid3X3, MousePointer, Save, Palette, Monitor } from 'lucide-react';
import { useUIStore } from '../../stores/ui-store';
import { Slider } from '@openreel/ui';
interface Props {
isOpen: boolean;
onClose: () => void;
}
type SettingsTab = 'canvas' | 'snapping' | 'appearance';
export function SettingsDialog({ isOpen, onClose }: Props) {
const [activeTab, setActiveTab] = useState<SettingsTab>('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: <Grid3X3 size={16} /> },
{ id: 'snapping', label: 'Snapping', icon: <MousePointer size={16} /> },
{ id: 'appearance', label: 'Appearance', icon: <Palette size={16} /> },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-background border border-border rounded-xl shadow-2xl w-full max-w-lg overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Settings size={20} className="text-primary" />
<h2 className="text-lg font-semibold">Settings</h2>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
<div className="flex">
<div className="w-40 border-r border-border p-2 space-y-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-primary/20 text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="flex-1 p-6 min-h-[300px]">
{activeTab === 'canvas' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Canvas Options</h3>
<div className="space-y-4">
<ToggleOption
label="Show Grid"
description="Display grid overlay on canvas"
checked={showGrid}
onChange={toggleGrid}
/>
<ToggleOption
label="Show Guides"
description="Display alignment guides"
checked={showGuides}
onChange={toggleGuides}
/>
<ToggleOption
label="Show Rulers"
description="Display rulers on edges"
checked={showRulers}
onChange={toggleRulers}
/>
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<label className="text-sm text-foreground">Grid Size</label>
<span className="text-sm text-muted-foreground">{gridSize}px</span>
</div>
<Slider
value={[gridSize]}
onValueChange={([value]) => setGridSize(value)}
min={5}
max={50}
step={5}
/>
</div>
</div>
</div>
)}
{activeTab === 'snapping' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Snap Options</h3>
<div className="space-y-4">
<ToggleOption
label="Snap to Grid"
description="Snap objects to grid intersections"
checked={snapToGrid}
onChange={toggleSnapToGrid}
/>
<ToggleOption
label="Snap to Guides"
description="Snap objects to guide lines"
checked={snapToGuides}
onChange={toggleSnapToGuides}
/>
<ToggleOption
label="Snap to Objects"
description="Snap objects to other objects"
checked={snapToObjects}
onChange={toggleSnapToObjects}
/>
</div>
</div>
)}
{activeTab === 'appearance' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-foreground mb-4">Appearance</h3>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center gap-3">
<Monitor size={18} className="text-muted-foreground" />
<div>
<p className="text-sm font-medium">Theme</p>
<p className="text-xs text-muted-foreground">Interface appearance</p>
</div>
</div>
<div className="px-3 py-1.5 text-xs bg-primary/20 text-primary rounded-md">
Dark (System)
</div>
</div>
<div className="p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center gap-3 mb-3">
<Save size={18} className="text-muted-foreground" />
<div>
<p className="text-sm font-medium">Auto Save</p>
<p className="text-xs text-muted-foreground">Automatically save projects</p>
</div>
</div>
<p className="text-xs text-muted-foreground">
Projects are automatically saved to browser storage every 30 seconds.
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
interface ToggleOptionProps {
label: string;
description: string;
checked: boolean;
onChange: () => void;
}
function ToggleOption({ label, description, checked, onChange }: ToggleOptionProps) {
return (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-foreground">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<button
onClick={onChange}
className={`relative w-10 h-5 rounded-full transition-colors ${
checked ? 'bg-primary' : 'bg-secondary'
}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
checked ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,363 @@
import { useEffect, useRef } from 'react';
import {
Copy,
Clipboard,
Scissors,
Trash2,
Eye,
EyeOff,
Lock,
Unlock,
ArrowUpToLine,
ArrowDownToLine,
ChevronUp,
ChevronDown,
FlipHorizontal,
FlipVertical,
RotateCcw,
FolderPlus,
FolderOpen,
Type,
Square,
Circle,
Triangle,
Star,
Hexagon,
Minus,
Grid3X3,
Ruler,
ZoomIn,
ZoomOut,
Maximize,
AlignLeft,
AlignCenter,
AlignRight,
AlignStartVertical,
AlignCenterVertical,
AlignEndVertical,
Paintbrush,
MousePointer,
} from 'lucide-react';
export interface ContextMenuPosition {
x: number;
y: number;
}
export type ContextMenuType = 'layer' | 'multi-layer' | 'canvas' | 'group';
interface MenuItem {
label: string;
icon?: React.ReactNode;
shortcut?: string;
action: () => void;
disabled?: boolean;
divider?: boolean;
submenu?: MenuItem[];
}
interface ContextMenuProps {
position: ContextMenuPosition;
type: ContextMenuType;
onClose: () => void;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onDuplicate: () => void;
onDelete: () => void;
onSelectAll: () => void;
onToggleVisibility: () => void;
onToggleLock: () => void;
onBringToFront: () => void;
onBringForward: () => void;
onSendBackward: () => void;
onSendToBack: () => void;
onGroup: () => void;
onUngroup: () => void;
onFlipHorizontal: () => void;
onFlipVertical: () => void;
onResetTransform: () => void;
onCopyStyle: () => void;
onPasteStyle: () => void;
onAddText: () => void;
onAddShape: (type: 'rectangle' | 'ellipse' | 'triangle' | 'star' | 'polygon' | 'line') => void;
onToggleGrid: () => void;
onToggleRulers: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomFit: () => void;
onAlignLeft: () => void;
onAlignCenter: () => void;
onAlignRight: () => void;
onAlignTop: () => void;
onAlignMiddle: () => void;
onAlignBottom: () => void;
isVisible: boolean;
isLocked: boolean;
showGrid: boolean;
showRulers: boolean;
hasClipboard: boolean;
hasStyleClipboard: boolean;
selectedCount: number;
}
export function ContextMenu({
position,
type,
onClose,
onCut,
onCopy,
onPaste,
onDuplicate,
onDelete,
onSelectAll,
onToggleVisibility,
onToggleLock,
onBringToFront,
onBringForward,
onSendBackward,
onSendToBack,
onGroup,
onUngroup,
onFlipHorizontal,
onFlipVertical,
onResetTransform,
onCopyStyle,
onPasteStyle,
onAddText,
onAddShape,
onToggleGrid,
onToggleRulers,
onZoomIn,
onZoomOut,
onZoomFit,
onAlignLeft,
onAlignCenter,
onAlignRight,
onAlignTop,
onAlignMiddle,
onAlignBottom,
isVisible,
isLocked,
showGrid,
showRulers,
hasClipboard,
hasStyleClipboard,
selectedCount,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(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: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⇧V', action: onPasteStyle, disabled: !hasStyleClipboard },
{ label: '', action: () => {}, divider: true },
{ label: 'Select All', icon: <MousePointer size={14} />, shortcut: '⌘A', action: onSelectAll },
{ label: '', action: () => {}, divider: true },
{ label: 'Add Text', icon: <Type size={14} />, shortcut: 'T', action: onAddText },
{
label: 'Add Shape',
icon: <Square size={14} />,
action: () => {},
submenu: [
{ label: 'Rectangle', icon: <Square size={14} />, action: () => onAddShape('rectangle') },
{ label: 'Ellipse', icon: <Circle size={14} />, action: () => onAddShape('ellipse') },
{ label: 'Triangle', icon: <Triangle size={14} />, action: () => onAddShape('triangle') },
{ label: 'Star', icon: <Star size={14} />, action: () => onAddShape('star') },
{ label: 'Polygon', icon: <Hexagon size={14} />, action: () => onAddShape('polygon') },
{ label: 'Line', icon: <Minus size={14} />, action: () => onAddShape('line') },
],
},
{ label: '', action: () => {}, divider: true },
{ label: showGrid ? 'Hide Grid' : 'Show Grid', icon: <Grid3X3 size={14} />, shortcut: "⌘'", action: onToggleGrid },
{ label: showRulers ? 'Hide Rulers' : 'Show Rulers', icon: <Ruler size={14} />, shortcut: '⌘R', action: onToggleRulers },
{ label: '', action: () => {}, divider: true },
{ label: 'Zoom In', icon: <ZoomIn size={14} />, shortcut: '⌘+', action: onZoomIn },
{ label: 'Zoom Out', icon: <ZoomOut size={14} />, shortcut: '⌘-', action: onZoomOut },
{ label: 'Zoom to Fit', icon: <Maximize size={14} />, shortcut: '⌘0', action: onZoomFit },
];
}
if (type === 'multi-layer') {
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: `Group ${selectedCount} Layers`, icon: <FolderPlus size={14} />, shortcut: '⌘G', action: onGroup },
{ label: '', action: () => {}, divider: true },
{
label: 'Align',
icon: <AlignLeft size={14} />,
action: () => {},
submenu: [
{ label: 'Align Left', icon: <AlignLeft size={14} />, action: onAlignLeft },
{ label: 'Align Center', icon: <AlignCenter size={14} />, action: onAlignCenter },
{ label: 'Align Right', icon: <AlignRight size={14} />, action: onAlignRight },
{ label: '', action: () => {}, divider: true },
{ label: 'Align Top', icon: <AlignStartVertical size={14} />, action: onAlignTop },
{ label: 'Align Middle', icon: <AlignCenterVertical size={14} />, action: onAlignMiddle },
{ label: 'Align Bottom', icon: <AlignEndVertical size={14} />, action: onAlignBottom },
],
},
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
];
}
if (type === 'group') {
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: 'Ungroup', icon: <FolderOpen size={14} />, shortcut: '⌘⇧G', action: onUngroup },
{ label: '', action: () => {}, divider: true },
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
{ label: '', action: () => {}, divider: true },
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
];
}
return [
{ label: 'Cut', icon: <Scissors size={14} />, shortcut: '⌘X', action: onCut },
{ label: 'Copy', icon: <Copy size={14} />, shortcut: '⌘C', action: onCopy },
{ label: 'Paste', icon: <Clipboard size={14} />, shortcut: '⌘V', action: onPaste, disabled: !hasClipboard },
{ label: 'Duplicate', icon: <Copy size={14} />, shortcut: '⌘D', action: onDuplicate },
{ label: 'Delete', icon: <Trash2 size={14} />, shortcut: '⌫', action: onDelete },
{ label: '', action: () => {}, divider: true },
{ label: 'Copy Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥C', action: onCopyStyle },
{ label: 'Paste Style', icon: <Paintbrush size={14} />, shortcut: '⌘⌥V', action: onPasteStyle, disabled: !hasStyleClipboard },
{ label: '', action: () => {}, divider: true },
{ label: isVisible ? 'Hide' : 'Show', icon: isVisible ? <EyeOff size={14} /> : <Eye size={14} />, shortcut: '⌘⇧H', action: onToggleVisibility },
{ label: isLocked ? 'Unlock' : 'Lock', icon: isLocked ? <Unlock size={14} /> : <Lock size={14} />, shortcut: '⌘⇧L', action: onToggleLock },
{ label: '', action: () => {}, divider: true },
{ label: 'Bring to Front', icon: <ArrowUpToLine size={14} />, shortcut: '⌘⇧]', action: onBringToFront },
{ label: 'Bring Forward', icon: <ChevronUp size={14} />, shortcut: '⌘]', action: onBringForward },
{ label: 'Send Backward', icon: <ChevronDown size={14} />, shortcut: '⌘[', action: onSendBackward },
{ label: 'Send to Back', icon: <ArrowDownToLine size={14} />, shortcut: '⌘⇧[', action: onSendToBack },
{ label: '', action: () => {}, divider: true },
{ label: 'Flip Horizontal', icon: <FlipHorizontal size={14} />, action: onFlipHorizontal },
{ label: 'Flip Vertical', icon: <FlipVertical size={14} />, action: onFlipVertical },
{ label: 'Reset Transform', icon: <RotateCcw size={14} />, action: onResetTransform },
];
};
const renderMenuItem = (item: MenuItem, index: number) => {
if (item.divider) {
return <div key={index} className="h-px bg-border my-1" />;
}
if (item.submenu) {
return (
<div key={index} className="relative group/submenu">
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-foreground hover:bg-accent rounded-sm transition-colors"
>
{item.icon && <span className="text-muted-foreground">{item.icon}</span>}
<span className="flex-1 text-left">{item.label}</span>
<ChevronUp size={12} className="text-muted-foreground rotate-90" />
</button>
<div className="absolute left-full top-0 ml-1 hidden group-hover/submenu:block">
<div className="bg-popover border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
{item.submenu.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))}
</div>
</div>
</div>
);
}
return (
<button
key={index}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs rounded-sm transition-colors ${
item.disabled
? 'text-muted-foreground/50 cursor-not-allowed'
: 'text-foreground hover:bg-accent'
}`}
>
{item.icon && <span className={item.disabled ? 'text-muted-foreground/50' : 'text-muted-foreground'}>{item.icon}</span>}
<span className="flex-1 text-left">{item.label}</span>
{item.shortcut && (
<span className="text-[10px] text-muted-foreground font-mono">{item.shortcut}</span>
)}
</button>
);
};
const menuItems = getMenuItems();
return (
<div
ref={menuRef}
className="fixed z-50 bg-popover border border-border rounded-lg shadow-xl py-1 min-w-[200px] animate-in fade-in-0 zoom-in-95 duration-100"
style={{ left: position.x, top: position.y }}
onContextMenu={(e) => e.preventDefault()}
>
{menuItems.map((item, index) => renderMenuItem(item, index))}
</div>
);
}

View file

@ -0,0 +1,206 @@
import { useEffect, useRef } from 'react';
import { useUIStore } from '../../../stores/ui-store';
import { useProjectStore } from '../../../stores/project-store';
const RULER_SIZE = 20;
const RULER_BG = '#1f1f23';
const RULER_TEXT = '#71717a';
const RULER_TICK = '#3f3f46';
const RULER_HIGHLIGHT = '#3b82f6';
interface RulersProps {
containerWidth: number;
containerHeight: number;
}
export function Rulers({ containerWidth, containerHeight }: RulersProps) {
const horizontalRef = useRef<HTMLCanvasElement>(null);
const verticalRef = useRef<HTMLCanvasElement>(null);
const cornerRef = useRef<HTMLDivElement>(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 (
<>
<div
ref={cornerRef}
className="absolute top-0 left-0 z-20"
style={{
width: RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
borderRight: `1px solid ${RULER_TICK}`,
borderBottom: `1px solid ${RULER_TICK}`,
}}
/>
<canvas
ref={horizontalRef}
className="absolute top-0 z-10"
style={{
left: RULER_SIZE,
width: containerWidth - RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
<canvas
ref={verticalRef}
className="absolute left-0 z-10"
style={{
top: RULER_SIZE,
width: RULER_SIZE,
height: containerHeight - RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
</>
);
}
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;
}

View file

@ -0,0 +1,240 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import {
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
AlignVerticalJustifyStart,
AlignVerticalJustifyCenter,
AlignVerticalJustifyEnd,
AlignHorizontalSpaceBetween,
AlignVerticalSpaceBetween,
} from 'lucide-react';
interface Props {
layers: Layer[];
}
export function AlignmentSection({ layers }: Props) {
const { project, selectedArtboardId, updateLayerTransform } = useProjectStore();
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
if (!artboard || layers.length === 0) return null;
const alignLeft = () => {
if (layers.length === 1) {
updateLayerTransform(layers[0].id, { x: 0 });
} else {
const minX = Math.min(...layers.map((l) => l.transform.x));
layers.forEach((layer) => {
updateLayerTransform(layer.id, { x: minX });
});
}
};
const alignCenterH = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
x: (artboard.size.width - layer.transform.width) / 2,
});
} else {
const bounds = getBounds(layers);
const centerX = bounds.x + bounds.width / 2;
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
x: centerX - layer.transform.width / 2,
});
});
}
};
const alignRight = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
x: artboard.size.width - layer.transform.width,
});
} else {
const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
x: maxRight - layer.transform.width,
});
});
}
};
const alignTop = () => {
if (layers.length === 1) {
updateLayerTransform(layers[0].id, { y: 0 });
} else {
const minY = Math.min(...layers.map((l) => l.transform.y));
layers.forEach((layer) => {
updateLayerTransform(layer.id, { y: minY });
});
}
};
const alignCenterV = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
y: (artboard.size.height - layer.transform.height) / 2,
});
} else {
const bounds = getBounds(layers);
const centerY = bounds.y + bounds.height / 2;
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
y: centerY - layer.transform.height / 2,
});
});
}
};
const alignBottom = () => {
if (layers.length === 1) {
const layer = layers[0];
updateLayerTransform(layer.id, {
y: artboard.size.height - layer.transform.height,
});
} else {
const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
layers.forEach((layer) => {
updateLayerTransform(layer.id, {
y: maxBottom - layer.transform.height,
});
});
}
};
const distributeH = () => {
if (layers.length < 3) return;
const sorted = [...layers].sort((a, b) => a.transform.x - b.transform.x);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalWidth = last.transform.x + last.transform.width - first.transform.x;
const layersWidth = sorted.reduce((sum, l) => sum + l.transform.width, 0);
const gap = (totalWidth - layersWidth) / (sorted.length - 1);
let x = first.transform.x;
sorted.forEach((layer) => {
updateLayerTransform(layer.id, { x });
x += layer.transform.width + gap;
});
};
const distributeV = () => {
if (layers.length < 3) return;
const sorted = [...layers].sort((a, b) => a.transform.y - b.transform.y);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const totalHeight = last.transform.y + last.transform.height - first.transform.y;
const layersHeight = sorted.reduce((sum, l) => sum + l.transform.height, 0);
const gap = (totalHeight - layersHeight) / (sorted.length - 1);
let y = first.transform.y;
sorted.forEach((layer) => {
updateLayerTransform(layer.id, { y });
y += layer.transform.height + gap;
});
};
const isSingleLayer = layers.length === 1;
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Alignment
</h4>
<div className="grid grid-cols-6 gap-1">
<AlignButton
icon={<AlignHorizontalJustifyStart size={14} />}
onClick={alignLeft}
title={isSingleLayer ? 'Align to canvas left' : 'Align left edges'}
/>
<AlignButton
icon={<AlignHorizontalJustifyCenter size={14} />}
onClick={alignCenterH}
title={isSingleLayer ? 'Center horizontally on canvas' : 'Align horizontal centers'}
/>
<AlignButton
icon={<AlignHorizontalJustifyEnd size={14} />}
onClick={alignRight}
title={isSingleLayer ? 'Align to canvas right' : 'Align right edges'}
/>
<AlignButton
icon={<AlignVerticalJustifyStart size={14} />}
onClick={alignTop}
title={isSingleLayer ? 'Align to canvas top' : 'Align top edges'}
/>
<AlignButton
icon={<AlignVerticalJustifyCenter size={14} />}
onClick={alignCenterV}
title={isSingleLayer ? 'Center vertically on canvas' : 'Align vertical centers'}
/>
<AlignButton
icon={<AlignVerticalJustifyEnd size={14} />}
onClick={alignBottom}
title={isSingleLayer ? 'Align to canvas bottom' : 'Align bottom edges'}
/>
</div>
{layers.length >= 3 && (
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-border">
<AlignButton
icon={<AlignHorizontalSpaceBetween size={14} />}
onClick={distributeH}
title="Distribute horizontally"
label="Distribute H"
/>
<AlignButton
icon={<AlignVerticalSpaceBetween size={14} />}
onClick={distributeV}
title="Distribute vertically"
label="Distribute V"
/>
</div>
)}
</div>
);
}
interface AlignButtonProps {
icon: React.ReactNode;
onClick: () => void;
title: string;
label?: string;
}
function AlignButton({ icon, onClick, title, label }: AlignButtonProps) {
return (
<button
onClick={onClick}
title={title}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
>
{icon}
{label && <span className="text-[9px]">{label}</span>}
</button>
);
}
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,
};
}

View file

@ -0,0 +1,180 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, BlendMode } from '../../../types/project';
interface Props {
layer: Layer;
}
const BLEND_MODES: BlendMode['mode'][] = [
'normal',
'multiply',
'screen',
'overlay',
'darken',
'lighten',
'color-dodge',
'color-burn',
'hard-light',
'soft-light',
'difference',
'exclusion',
];
export function AppearanceSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const handleBlendModeChange = (mode: BlendMode['mode']) => {
updateLayer(layer.id, { blendMode: { mode } });
};
const handleShadowToggle = () => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, enabled: !layer.shadow.enabled },
});
};
const handleShadowChange = (key: string, value: string | number) => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, [key]: value },
});
};
const handleStrokeToggle = () => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, enabled: !layer.stroke.enabled },
});
};
const handleStrokeChange = (key: string, value: string | number) => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, [key]: value },
});
};
return (
<div className="space-y-4">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Blend Mode</label>
<select
value={layer.blendMode.mode}
onChange={(e) => handleBlendModeChange(e.target.value as BlendMode['mode'])}
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 capitalize"
>
{BLEND_MODES.map((mode) => (
<option key={mode} value={mode} className="capitalize">
{mode.replace('-', ' ')}
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Drop Shadow</label>
<button
onClick={handleShadowToggle}
className={`w-8 h-5 rounded-full transition-colors ${
layer.shadow.enabled ? 'bg-primary' : 'bg-secondary'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
layer.shadow.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{layer.shadow.enabled && (
<div className="pl-2 border-l-2 border-border space-y-2">
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Color</label>
<input
type="color"
value={layer.shadow.color.replace(/rgba?\([^)]+\)/, '#000000')}
onChange={(e) => handleShadowChange('color', e.target.value)}
className="w-6 h-6 rounded border border-input cursor-pointer"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Blur</label>
<input
type="range"
value={layer.shadow.blur}
onChange={(e) => handleShadowChange('blur', Number(e.target.value))}
min={0}
max={50}
className="flex-1 h-1 accent-primary"
/>
<span className="text-[10px] text-muted-foreground w-6 text-right">
{layer.shadow.blur}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">X</label>
<input
type="number"
value={layer.shadow.offsetX}
onChange={(e) => handleShadowChange('offsetX', Number(e.target.value))}
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
/>
<label className="text-[10px] text-muted-foreground w-4">Y</label>
<input
type="number"
value={layer.shadow.offsetY}
onChange={(e) => handleShadowChange('offsetY', Number(e.target.value))}
className="w-16 px-2 py-1 text-xs bg-background border border-input rounded-md"
/>
</div>
</div>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Stroke</label>
<button
onClick={handleStrokeToggle}
className={`w-8 h-5 rounded-full transition-colors ${
layer.stroke.enabled ? 'bg-primary' : 'bg-secondary'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow transition-transform ${
layer.stroke.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{layer.stroke.enabled && (
<div className="pl-2 border-l-2 border-border space-y-2">
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Color</label>
<input
type="color"
value={layer.stroke.color}
onChange={(e) => handleStrokeChange('color', e.target.value)}
className="w-6 h-6 rounded border border-input cursor-pointer"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground w-12">Width</label>
<input
type="range"
value={layer.stroke.width}
onChange={(e) => handleStrokeChange('width', Number(e.target.value))}
min={1}
max={20}
className="flex-1 h-1 accent-primary"
/>
<span className="text-[10px] text-muted-foreground w-6 text-right">
{layer.stroke.width}
</span>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,136 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Artboard, CanvasBackground } from '../../../types/project';
interface Props {
artboard: Artboard;
}
export function ArtboardSection({ artboard }: Props) {
const { updateArtboard } = useProjectStore();
const handleSizeChange = (key: 'width' | 'height', value: number) => {
updateArtboard(artboard.id, {
size: { ...artboard.size, [key]: value },
});
};
const handleBackgroundTypeChange = (type: CanvasBackground['type']) => {
let background: CanvasBackground;
switch (type) {
case 'color':
background = { type: 'color', color: '#ffffff' };
break;
case 'transparent':
background = { type: 'transparent' };
break;
case 'gradient':
background = {
type: 'gradient',
gradient: {
type: 'linear',
angle: 180,
stops: [
{ offset: 0, color: '#ffffff' },
{ offset: 1, color: '#000000' },
],
},
};
break;
default:
background = { type: 'color', color: '#ffffff' };
}
updateArtboard(artboard.id, { background });
};
const handleBackgroundColorChange = (color: string) => {
updateArtboard(artboard.id, {
background: { type: 'color', color },
});
};
return (
<div className="space-y-4">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Name</label>
<input
type="text"
value={artboard.name}
onChange={(e) => 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"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Width</label>
<input
type="number"
value={artboard.size.width}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Height</label>
<input
type="number"
value={artboard.size.height}
onChange={(e) => 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}
/>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Background</label>
<select
value={artboard.background.type}
onChange={(e) => handleBackgroundTypeChange(e.target.value as CanvasBackground['type'])}
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 mb-2"
>
<option value="color">Solid Color</option>
<option value="transparent">Transparent</option>
<option value="gradient">Gradient</option>
</select>
{artboard.background.type === 'color' && (
<div className="flex items-center gap-2">
<input
type="color"
value={artboard.background.color ?? '#ffffff'}
onChange={(e) => handleBackgroundColorChange(e.target.value)}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={artboard.background.color ?? '#ffffff'}
onChange={(e) => 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"
/>
</div>
)}
{artboard.background.type === 'transparent' && (
<div className="p-3 rounded-md bg-background border border-input">
<div
className="h-8 rounded"
style={{
backgroundImage:
'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)',
backgroundSize: '10px 10px',
backgroundPosition: '0 0, 0 5px, 5px -5px, -5px 0px',
}}
/>
<p className="text-[10px] text-muted-foreground mt-2 text-center">
Transparency pattern
</p>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,169 @@
import { useState } from 'react';
import { Wand2, Loader2 } from 'lucide-react';
import { Slider } from '@openreel/ui';
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer } from '../../../types/project';
import {
getBackgroundRemovalService,
BackgroundMode,
DEFAULT_OPTIONS,
} from '../../../services/background-removal-service';
interface Props {
layer: ImageLayer;
}
export function BackgroundRemovalSection({ layer }: Props) {
const { project, addAsset, updateLayer } = useProjectStore();
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [mode, setMode] = useState<BackgroundMode>('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<ImageLayer>(layer.id, { sourceId: newAssetId });
} catch (error) {
console.error('Background removal failed:', error);
} finally {
setIsProcessing(false);
setProgress(0);
}
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Background Removal
</h4>
<div className="p-3 space-y-4 bg-secondary/50 rounded-lg">
<div className="space-y-2">
<label className="text-[10px] text-muted-foreground">Mode</label>
<div className="grid grid-cols-3 gap-1">
{(['transparent', 'color', 'blur'] as BackgroundMode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
mode === m
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
>
{m.charAt(0).toUpperCase() + m.slice(1)}
</button>
))}
</div>
</div>
{mode === 'color' && (
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted-foreground">Background</label>
<input
type="color"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={backgroundColor}
onChange={(e) => setBackgroundColor(e.target.value)}
className="flex-1 px-2 py-1 text-xs bg-background border border-input rounded-md font-mono"
/>
</div>
)}
{mode === 'blur' && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur Amount</label>
<span className="text-[10px] text-muted-foreground">{blurAmount}px</span>
</div>
<Slider
value={[blurAmount]}
onValueChange={([v]) => setBlurAmount(v)}
min={5}
max={30}
step={1}
/>
</div>
)}
{isProcessing && (
<div className="space-y-1.5">
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground text-center">
{progress < 15 ? 'Loading AI model...' :
progress < 90 ? 'Analyzing image...' :
'Finalizing...'}
{' '}{Math.round(progress)}%
</p>
</div>
)}
<button
onClick={handleRemoveBackground}
disabled={isProcessing}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
Processing...
</>
) : (
<>
<Wand2 size={16} />
Remove Background
</>
)}
</button>
<p className="text-[9px] text-muted-foreground text-center">
AI-powered background removal for any image
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,226 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { BlackWhiteAdjustment } from '../../../types/adjustments';
import { DEFAULT_BLACK_WHITE } from '../../../types/adjustments';
import { BLACK_WHITE_PRESETS } from '../../../adjustments/black-white';
import { SunMoon, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
const COLOR_SLIDERS: { key: keyof BlackWhiteAdjustment; label: string; color: string }[] = [
{ key: 'reds', label: 'Reds', color: 'bg-red-500' },
{ key: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
{ key: 'greens', label: 'Greens', color: 'bg-green-500' },
{ key: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
{ key: 'blues', label: 'Blues', color: 'bg-blue-500' },
{ key: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
];
const PRESET_OPTIONS = [
{ id: 'default', label: 'Default' },
{ id: 'highContrast', label: 'High Contrast' },
{ id: 'infrared', label: 'Infrared' },
{ id: 'maximumBlack', label: 'Maximum Black' },
{ id: 'maximumWhite', label: 'Maximum White' },
{ id: 'neutralDensity', label: 'Neutral Density' },
{ id: 'redFilter', label: 'Red Filter' },
{ id: 'yellowFilter', label: 'Yellow Filter' },
{ id: 'greenFilter', label: 'Green Filter' },
{ id: 'blueFilter', label: 'Blue Filter' },
] as const;
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
const percentage = ((value + 200) / 400) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-200}
max={200}
onChange={(e) => 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%)`
}}
/>
</div>
);
}
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 (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<SunMoon size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Black & White</span>
{blackWhite.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={blackWhite.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<select
value=""
onChange={(e) => handlePresetChange(e.target.value)}
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground"
>
<option value="">Preset</option>
{PRESET_OPTIONS.map((preset) => (
<option key={preset.id} value={preset.id}>{preset.label}</option>
))}
</select>
<button
onClick={resetBlackWhite}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
{COLOR_SLIDERS.map(({ key, label, color }) => (
<ChannelSlider
key={key}
label={label}
color={color}
value={blackWhite[key] as number}
onChange={(v) => handleValueChange(key, v)}
/>
))}
</div>
<div className="space-y-2 pt-2 border-t border-border/50">
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={blackWhite.tintEnabled}
onChange={(e) => handleValueChange('tintEnabled', e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Tint
</label>
{blackWhite.tintEnabled && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Hue</span>
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintHue}°</span>
</div>
<input
type="range"
value={blackWhite.tintHue}
min={0}
max={360}
onChange={(e) => 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%))`
}}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Saturation</span>
<span className="text-[10px] font-mono text-muted-foreground">{blackWhite.tintSaturation}%</span>
</div>
<input
type="range"
value={blackWhite.tintSaturation}
min={0}
max={100}
onChange={(e) => 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"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,121 @@
import { useUIStore } from '../../../stores/ui-store';
import { Droplets, RotateCcw } from 'lucide-react';
export function BlurSharpenToolPanel() {
const { blurSharpenSettings, setBlurSharpenSettings } = useUIStore();
const resetSettings = () => {
setBlurSharpenSettings({
size: 30,
strength: 50,
mode: 'blur',
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Droplets size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">
{blurSharpenSettings.mode === 'blur' ? 'Blur' : 'Sharpen'} Tool
</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
{blurSharpenSettings.mode === 'blur'
? 'Paint to blur and soften areas.'
: 'Paint to sharpen and enhance details.'}
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setBlurSharpenSettings({ mode: 'blur' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
blurSharpenSettings.mode === 'blur'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Blur
</button>
<button
onClick={() => setBlurSharpenSettings({ mode: 'sharpen' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
blurSharpenSettings.mode === 'sharpen'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Sharpen
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={blurSharpenSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Strength</span>
<span className="text-xs font-mono text-muted-foreground">{blurSharpenSettings.strength}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={blurSharpenSettings.strength}
onChange={(e) => 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"
/>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={blurSharpenSettings.sampleAllLayers}
onChange={(e) => setBlurSharpenSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,156 @@
import { useUIStore } from '../../../stores/ui-store';
import { Paintbrush, RotateCcw } from 'lucide-react';
export function BrushToolPanel() {
const { brushSettings, setBrushSettings } = useUIStore();
const resetSettings = () => {
setBrushSettings({
size: 20,
hardness: 100,
opacity: 1,
flow: 1,
color: '#000000',
blendMode: 'normal',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Paintbrush size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Color</span>
</div>
<div className="flex gap-2">
<input
type="color"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="w-10 h-10 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="flex-1 px-2 py-1 text-xs font-mono bg-secondary/50 border border-border rounded"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{brushSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={brushSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{brushSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.hardness}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.opacity * 100}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(brushSettings.flow * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={brushSettings.flow * 100}
onChange={(e) => 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"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Blend Mode</span>
<div className="grid grid-cols-2 gap-1">
{(['normal', 'multiply', 'screen', 'overlay'] as const).map((mode) => (
<button
key={mode}
onClick={() => setBrushSettings({ blendMode: mode })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
brushSettings.blendMode === mode
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{mode}
</button>
))}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,170 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { ChannelMixerAdjustment, ChannelMixerChannel } from '../../../types/adjustments';
import { DEFAULT_CHANNEL_MIXER } from '../../../types/adjustments';
import { Blend, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type OutputChannel = 'red' | 'green' | 'blue';
const CHANNEL_COLORS: Record<OutputChannel, string> = {
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 (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${color}`} />
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-200}
max={200}
onChange={(e) => 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%)`
}}
/>
</div>
);
}
export function ChannelMixerSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<OutputChannel>('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 (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Blend size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Channel Mixer</span>
{channelMixer.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={channelMixer.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['red', 'green', 'blue'] as OutputChannel[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-2 h-2 rounded-full mr-1 ${CHANNEL_COLORS[channel]}`} />
{channel.charAt(0).toUpperCase() + channel.slice(1)}
</button>
))}
</div>
<button
onClick={resetChannelMixer}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
<ChannelSlider label="Red" color="bg-red-500" value={currentChannel.red} onChange={(v) => handleValueChange('red', v)} />
<ChannelSlider label="Green" color="bg-green-500" value={currentChannel.green} onChange={(v) => handleValueChange('green', v)} />
<ChannelSlider label="Blue" color="bg-blue-500" value={currentChannel.blue} onChange={(v) => handleValueChange('blue', v)} />
<ChannelSlider label="Constant" color="bg-gray-500" value={currentChannel.constant} onChange={(v) => handleValueChange('constant', v)} />
</div>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border/50">
<input
type="checkbox"
checked={channelMixer.monochrome}
onChange={(e) => handleMonochromeChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Monochrome
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,149 @@
import { useUIStore } from '../../../stores/ui-store';
import { Stamp, RotateCcw } from 'lucide-react';
export function CloneStampToolPanel() {
const { cloneStampSettings, setCloneStampSettings } = useUIStore();
const resetSettings = () => {
setCloneStampSettings({
size: 30,
hardness: 50,
opacity: 1,
flow: 1,
aligned: true,
sampleAllLayers: false,
sourcePoint: null,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stamp size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Clone Stamp</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Hold Alt/Option and click to set source point, then paint to clone.
</p>
{cloneStampSettings.sourcePoint && (
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
Source: ({Math.round(cloneStampSettings.sourcePoint.x)}, {Math.round(cloneStampSettings.sourcePoint.y)})
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={cloneStampSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{cloneStampSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.hardness}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.opacity * 100}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(cloneStampSettings.flow * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={cloneStampSettings.flow * 100}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={cloneStampSettings.aligned}
onChange={(e) => setCloneStampSettings({ aligned: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Aligned
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={cloneStampSettings.sampleAllLayers}
onChange={(e) => setCloneStampSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { ColorBalanceValues } from '../../../types/adjustments';
import { DEFAULT_COLOR_BALANCE } from '../../../types/adjustments';
import { Palette, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ToneType = 'shadows' | 'midtones' | 'highlights';
interface BalanceSliderProps {
leftLabel: string;
rightLabel: string;
leftColor: string;
rightColor: string;
value: number;
onChange: (value: number) => void;
}
function BalanceSlider({
leftLabel,
rightLabel,
leftColor,
rightColor,
value,
onChange,
}: BalanceSliderProps) {
const percentage = ((value + 100) / 200) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px]" style={{ color: leftColor }}>
{leftLabel}
</span>
<span className="text-[10px] font-mono text-muted-foreground">{value}</span>
<span className="text-[10px]" style={{ color: rightColor }}>
{rightLabel}
</span>
</div>
<input
type="range"
value={value}
min={-100}
max={100}
onChange={(e) => 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%)`,
}}
/>
</div>
);
}
export function ColorBalanceSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeTone, setActiveTone] = useState<ToneType>('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 (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Color Balance</span>
{colorBalance.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={colorBalance.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{tones.map((tone) => (
<button
key={tone.id}
onClick={() => setActiveTone(tone.id)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeTone === tone.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tone.label}
</button>
))}
</div>
<button
onClick={resetColorBalance}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Color Balance"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-3 pt-1">
<BalanceSlider
leftLabel="Cyan"
rightLabel="Red"
leftColor="#00bcd4"
rightColor="#f44336"
value={currentTone.cyanRed}
onChange={(v) => handleToneChange('cyanRed', v)}
/>
<BalanceSlider
leftLabel="Magenta"
rightLabel="Green"
leftColor="#e91e63"
rightColor="#4caf50"
value={currentTone.magentaGreen}
onChange={(v) => handleToneChange('magentaGreen', v)}
/>
<BalanceSlider
leftLabel="Yellow"
rightLabel="Blue"
leftColor="#ffeb3b"
rightColor="#2196f3"
value={currentTone.yellowBlue}
onChange={(v) => handleToneChange('yellowBlue', v)}
/>
</div>
<label className="flex items-center gap-2 pt-2 border-t border-border">
<input
type="checkbox"
checked={colorBalance.preserveLuminosity}
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Preserve Luminosity</span>
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,107 @@
import { useState } from 'react';
import { getAllHarmonies, type HarmonyType } from '../../../utils/color-harmony';
import { Palette, Copy, Check } from 'lucide-react';
import { ColorPalettes, QuickColorSwatches } from '../../ui/ColorPalettes';
import { SavedColorsSection } from '../../ui/SavedColorsSection';
import { useColorStore } from '../../../stores/color-store';
interface Props {
baseColor: string;
onColorSelect?: (color: string) => void;
}
export function ColorHarmonySection({ baseColor, onColorSelect }: Props) {
const [copiedColor, setCopiedColor] = useState<string | null>(null);
const [selectedHarmony, setSelectedHarmony] = useState<HarmonyType>('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 (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Color Harmony
</h4>
</div>
<div className="flex flex-wrap gap-1">
{harmonies.map((harmony) => (
<button
key={harmony.type}
onClick={() => setSelectedHarmony(harmony.type)}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
selectedHarmony === harmony.type
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{harmony.name}
</button>
))}
</div>
<div className="p-3 bg-secondary/50 rounded-lg space-y-2">
<div className="flex gap-1.5">
{activeHarmony.colors.map((color, index) => (
<div key={index} className="flex-1 flex flex-col items-center gap-1">
<button
onClick={() => handleColorSelect(color)}
className="w-full aspect-square rounded-lg border border-border hover:ring-2 hover:ring-primary/50 transition-all cursor-pointer"
style={{ backgroundColor: color }}
title={`Click to apply ${color}`}
/>
<button
onClick={() => handleCopyColor(color)}
className="flex items-center gap-1 text-[9px] text-muted-foreground hover:text-foreground transition-colors"
>
{copiedColor === color ? (
<Check size={10} className="text-green-500" />
) : (
<Copy size={10} />
)}
<span className="font-mono">{color.toUpperCase()}</span>
</button>
</div>
))}
</div>
<p className="text-[9px] text-muted-foreground text-center">
Click a color to apply, or copy its hex code
</p>
</div>
{onColorSelect && (
<>
<SavedColorsSection
onColorSelect={handleColorSelect}
selectedColor={baseColor}
currentColor={baseColor}
/>
<QuickColorSwatches onColorSelect={handleColorSelect} selectedColor={baseColor} />
<ColorPalettes onColorSelect={handleColorSelect} selectedColor={baseColor} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,308 @@
import { useCallback, useMemo } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useUIStore, CropAspectRatio } from '../../../stores/ui-store';
import type { ImageLayer } from '../../../types/project';
import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react';
const imageCache = new Map<string, HTMLImageElement>();
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<ImageLayer>(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<ImageLayer>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Crop</h4>
{hasCrop && !isCropping && (
<button
onClick={handleResetCrop}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{!isCropping ? (
<button
onClick={handleStartCrop}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors"
>
<Crop size={16} />
{hasCrop ? 'Adjust Crop' : 'Crop Image'}
</button>
) : (
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-foreground">Aspect Ratio</label>
<button
onClick={() => setLockAspect(!lockAspect)}
className={`p-1 rounded transition-colors ${
lockAspect ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
{lockAspect ? <Lock size={12} /> : <Unlock size={12} />}
</button>
</div>
<div className="grid grid-cols-3 gap-1">
{ASPECT_RATIOS.map((ar) => (
<button
key={ar.value}
onClick={() => handleAspectRatioChange(ar.value)}
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
crop.aspectRatio === ar.value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80'
}`}
>
{ar.label}
</button>
))}
</div>
</div>
{crop.cropRect && (
<div className="space-y-2">
<label className="text-[11px] font-medium text-foreground">Crop Area</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">X</label>
<input
type="number"
value={Math.round(crop.cropRect.x)}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Y</label>
<input
type="number"
value={Math.round(crop.cropRect.y)}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Width</label>
<input
type="number"
value={Math.round(crop.cropRect.width)}
onChange={(e) =>
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"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Height</label>
<input
type="number"
value={Math.round(crop.cropRect.height)}
onChange={(e) =>
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"
/>
</div>
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleResetCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<RotateCcw size={12} />
Reset
</button>
<button
onClick={cancelCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<X size={12} />
Cancel
</button>
<button
onClick={handleApplyCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg font-medium text-[11px] hover:bg-primary/90 transition-colors"
>
<Check size={12} />
Apply
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,267 @@
import { useState, useRef, useCallback } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { CurvePoint } from '../../../types/adjustments';
import { DEFAULT_CURVES } from '../../../types/adjustments';
import { TrendingUp, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ChannelType = 'master' | 'red' | 'green' | 'blue';
interface CurveEditorProps {
points: CurvePoint[];
onChange: (points: CurvePoint[]) => void;
channel: ChannelType;
}
function CurveEditor({ points, onChange, channel }: CurveEditorProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
const channelColors: Record<ChannelType, string> = {
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 (
<div className="relative">
<svg
ref={svgRef}
viewBox="0 0 100 100"
className="w-full h-32 bg-secondary/50 rounded border border-border cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={handleClick}
>
<defs>
<pattern id="grid" width="25" height="25" patternUnits="userSpaceOnUse">
<path d="M 25 0 L 0 0 0 25" fill="none" stroke="hsl(var(--border))" strokeWidth="0.5" opacity="0.5" />
</pattern>
</defs>
<rect width="100" height="100" fill="url(#grid)" />
<line x1="0" y1="100" x2="100" y2="0" stroke="hsl(var(--muted-foreground))" strokeWidth="0.5" strokeDasharray="2 2" opacity="0.3" />
<path d={getPathD()} fill="none" stroke={channelColors[channel]} strokeWidth="2" />
{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 (
<circle
key={index}
cx={x}
cy={y}
r={isDragging || isHovered ? 4 : 3}
fill={isEndpoint ? 'hsl(var(--muted-foreground))' : channelColors[channel]}
stroke="hsl(var(--background))"
strokeWidth="1"
className={isEndpoint ? 'cursor-not-allowed' : 'cursor-move'}
onMouseDown={(e) => handleMouseDown(index, e)}
onDoubleClick={(e) => handleDoubleClick(index, e)}
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
/>
);
})}
</svg>
<div className="flex justify-between mt-1 text-[9px] text-muted-foreground">
<span>0</span>
<span>Input</span>
<span>255</span>
</div>
</div>
);
}
export function CurvesSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<ChannelType>('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<ChannelType, string> = {
master: 'bg-foreground',
red: 'bg-red-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<TrendingUp size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Curves</span>
{curves.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={curves.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
{channel.charAt(0).toUpperCase()}
</button>
))}
</div>
<button
onClick={resetCurves}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Curves"
>
<RotateCcw size={12} />
</button>
</div>
<CurveEditor
points={curves[activeChannel].points}
onChange={handlePointsChange}
channel={activeChannel}
/>
<p className="text-[9px] text-muted-foreground text-center">
Click to add point Double-click to remove Drag to adjust
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,167 @@
import { useUIStore } from '../../../stores/ui-store';
import { Sun, Moon } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => 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%)`,
}}
/>
</div>
);
}
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 (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
{dodgeBurnSettings.type === 'dodge' ? (
<Sun size={14} className="text-muted-foreground" />
) : (
<Moon size={14} className="text-muted-foreground" />
)}
<span className="text-xs font-medium">
{dodgeBurnSettings.type === 'dodge' ? 'Dodge Tool' : 'Burn Tool'}
</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tool</span>
<div className="flex gap-1">
{toolTypes.map((type) => (
<button
key={type.id}
onClick={() => setDodgeBurnSettings({ type: type.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.type === type.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<type.icon size={12} />
{type.label}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Range</span>
<div className="flex gap-1">
{ranges.map((range) => (
<button
key={range.id}
onClick={() => setDodgeBurnSettings({ range: range.id })}
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.range === range.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
{range.label}
</button>
))}
</div>
</div>
<Slider
label="Exposure"
value={dodgeBurnSettings.exposure}
min={1}
max={100}
unit="%"
onChange={(v) => setDodgeBurnSettings({ exposure: v })}
/>
<Slider
label="Size"
value={dodgeBurnSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setDodgeBurnSettings({ size: v })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full transition-all"
style={{
width: Math.min(dodgeBurnSettings.size, 80),
height: Math.min(dodgeBurnSettings.size, 80),
background:
dodgeBurnSettings.type === 'dodge'
? `radial-gradient(circle, rgba(255,255,255,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`
: `radial-gradient(circle, rgba(0,0,0,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
{dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} {dodgeBurnSettings.range}
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} selected tonal range</li>
<li> Lower exposure for subtle adjustments</li>
<li> Build up effect with multiple strokes</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,379 @@
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, Shadow, InnerShadow, Stroke, Glow } from '../../../types/project';
import { Slider } from '@openreel/ui';
import { ChevronDown, Droplets, Pencil, Sparkles, CircleDot } from 'lucide-react';
import { useState } from 'react';
interface Props {
layer: Layer;
}
type EffectSection = 'shadow' | 'innerShadow' | 'stroke' | 'glow' | null;
interface EffectHeaderProps {
icon: React.ElementType;
label: string;
enabled: boolean;
isOpen: boolean;
onToggle: () => void;
onEnabledChange: (enabled: boolean) => void;
}
function EffectHeader({ icon: Icon, label, enabled, isOpen, onToggle, onEnabledChange }: EffectHeaderProps) {
return (
<div className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<button
onClick={onToggle}
className="flex items-center gap-2 flex-1 text-left"
>
<Icon size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">{label}</span>
</button>
<div className="flex items-center gap-2">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onEnabledChange(e.target.checked)}
className="sr-only peer"
/>
<div className="w-8 h-4 bg-muted rounded-full peer peer-checked:bg-primary transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:after:translate-x-4" />
</label>
<button onClick={onToggle} className="p-0.5">
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
);
}
export function EffectsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [openSection, setOpenSection] = useState<EffectSection>('shadow');
const handleShadowChange = (updates: Partial<Shadow>) => {
updateLayer(layer.id, {
shadow: { ...layer.shadow, ...updates },
});
};
const handleInnerShadowChange = (updates: Partial<InnerShadow>) => {
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<Stroke>) => {
updateLayer(layer.id, {
stroke: { ...layer.stroke, ...updates },
});
};
const handleGlowChange = (updates: Partial<Glow>) => {
updateLayer(layer.id, {
glow: { ...layer.glow, ...updates },
});
};
const toggleSection = (section: EffectSection) => {
setOpenSection(openSection === section ? null : section);
};
return (
<div className="px-4 space-y-2">
<div>
<EffectHeader
icon={Droplets}
label="Drop Shadow"
enabled={layer.shadow.enabled}
isOpen={openSection === 'shadow'}
onToggle={() => toggleSection('shadow')}
onEnabledChange={(enabled) => handleShadowChange({ enabled })}
/>
{openSection === 'shadow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shadow.color.startsWith('rgba') ? '#000000' : layer.shadow.color}
onChange={(e) => handleShadowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.shadow.enabled}
/>
<input
type="text"
value={layer.shadow.color}
onChange={(e) => 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}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.blur}px</span>
</div>
<Slider
value={[layer.shadow.blur]}
onValueChange={([blur]) => handleShadowChange({ blur })}
min={0}
max={100}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetX}px</span>
</div>
<Slider
value={[layer.shadow.offsetX]}
onValueChange={([offsetX]) => handleShadowChange({ offsetX })}
min={-50}
max={50}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.shadow.offsetY}px</span>
</div>
<Slider
value={[layer.shadow.offsetY]}
onValueChange={([offsetY]) => handleShadowChange({ offsetY })}
min={-50}
max={50}
step={1}
disabled={!layer.shadow.enabled}
/>
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={CircleDot}
label="Inner Shadow"
enabled={layer.innerShadow?.enabled ?? false}
isOpen={openSection === 'innerShadow'}
onToggle={() => toggleSection('innerShadow')}
onEnabledChange={(enabled) => handleInnerShadowChange({ enabled })}
/>
{openSection === 'innerShadow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={(layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)').startsWith('rgba') ? '#000000' : layer.innerShadow?.color ?? '#000000'}
onChange={(e) => handleInnerShadowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.innerShadow?.enabled}
/>
<input
type="text"
value={layer.innerShadow?.color ?? 'rgba(0, 0, 0, 0.5)'}
onChange={(e) => 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}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.blur ?? 10}px</span>
</div>
<Slider
value={[layer.innerShadow?.blur ?? 10]}
onValueChange={([blur]) => handleInnerShadowChange({ blur })}
min={0}
max={50}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetX ?? 2}px</span>
</div>
<Slider
value={[layer.innerShadow?.offsetX ?? 2]}
onValueChange={([offsetX]) => handleInnerShadowChange({ offsetX })}
min={-30}
max={30}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.innerShadow?.offsetY ?? 2}px</span>
</div>
<Slider
value={[layer.innerShadow?.offsetY ?? 2]}
onValueChange={([offsetY]) => handleInnerShadowChange({ offsetY })}
min={-30}
max={30}
step={1}
disabled={!layer.innerShadow?.enabled}
/>
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={Pencil}
label="Stroke"
enabled={layer.stroke.enabled}
isOpen={openSection === 'stroke'}
onToggle={() => toggleSection('stroke')}
onEnabledChange={(enabled) => handleStrokeChange({ enabled })}
/>
{openSection === 'stroke' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.stroke.color}
onChange={(e) => handleStrokeChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.stroke.enabled}
/>
<input
type="text"
value={layer.stroke.color}
onChange={(e) => 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}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.stroke.width}px</span>
</div>
<Slider
value={[layer.stroke.width]}
onValueChange={([width]) => handleStrokeChange({ width })}
min={1}
max={20}
step={1}
disabled={!layer.stroke.enabled}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Style</label>
<div className="grid grid-cols-3 gap-1">
{(['solid', 'dashed', 'dotted'] as const).map((style) => (
<button
key={style}
onClick={() => handleStrokeChange({ style })}
disabled={!layer.stroke.enabled}
className={`px-2 py-1.5 text-[10px] rounded capitalize transition-colors ${
layer.stroke.style === style
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent disabled:opacity-50'
}`}
>
{style}
</button>
))}
</div>
</div>
</div>
)}
</div>
<div>
<EffectHeader
icon={Sparkles}
label="Outer Glow"
enabled={layer.glow.enabled}
isOpen={openSection === 'glow'}
onToggle={() => toggleSection('glow')}
onEnabledChange={(enabled) => handleGlowChange({ enabled })}
/>
{openSection === 'glow' && (
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.glow.color}
onChange={(e) => handleGlowChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
disabled={!layer.glow.enabled}
/>
<input
type="text"
value={layer.glow.color}
onChange={(e) => 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}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.glow.blur}px</span>
</div>
<Slider
value={[layer.glow.blur]}
onValueChange={([blur]) => handleGlowChange({ blur })}
min={0}
max={100}
step={1}
disabled={!layer.glow.enabled}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Intensity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.glow.intensity * 100)}%</span>
</div>
<Slider
value={[layer.glow.intensity]}
onValueChange={([intensity]) => handleGlowChange({ intensity })}
min={0}
max={2}
step={0.1}
disabled={!layer.glow.enabled}
/>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,153 @@
import { useUIStore } from '../../../stores/ui-store';
import { Eraser, Square, Pencil, Circle } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => 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%)`,
}}
/>
</div>
);
}
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 (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
<Eraser size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Eraser Tool</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
<div className="flex gap-1">
{eraserModes.map((mode) => (
<button
key={mode.id}
onClick={() => setEraserSettings({ mode: mode.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
eraserSettings.mode === mode.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<mode.icon size={12} />
{mode.label}
</button>
))}
</div>
</div>
<Slider
label="Size"
value={eraserSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setEraserSettings({ size: v })}
/>
<Slider
label="Hardness"
value={eraserSettings.hardness}
min={0}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ hardness: v })}
/>
<Slider
label="Opacity"
value={Math.round(eraserSettings.opacity * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ opacity: v / 100 })}
/>
<Slider
label="Flow"
value={Math.round(eraserSettings.flow * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ flow: v / 100 })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full bg-foreground transition-all"
style={{
width: Math.min(eraserSettings.size, 100),
height: Math.min(eraserSettings.size, 100),
opacity: eraserSettings.opacity,
filter: `blur(${(100 - eraserSettings.hardness) / 20}px)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
Brush preview
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> Hold Shift for straight lines</li>
<li> [ and ] to adjust size</li>
<li> Shift+[ and ] for hardness</li>
</ul>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,287 @@
import { useState, useMemo } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer, Filter } from '../../../types/project';
import { Sparkles, Check } from 'lucide-react';
interface Props {
layer: ImageLayer;
}
interface FilterPreset {
id: string;
name: string;
category: 'basic' | 'vintage' | 'cinematic' | 'mood';
filters: Filter;
thumbnail?: string;
}
const FILTER_PRESETS: FilterPreset[] = [
{
id: 'original',
name: 'Original',
category: 'basic',
filters: { brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'vivid',
name: 'Vivid',
category: 'basic',
filters: { brightness: 105, contrast: 115, saturation: 130, hue: 0, exposure: 0, vibrance: 30, highlights: 0, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'warm',
name: 'Warm',
category: 'mood',
filters: { brightness: 105, contrast: 105, saturation: 110, hue: 15, exposure: 5, vibrance: 15, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'cool',
name: 'Cool',
category: 'mood',
filters: { brightness: 100, contrast: 105, saturation: 95, hue: -15, exposure: 0, vibrance: 0, highlights: 5, shadows: 0, clarity: 5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'bw',
name: 'B&W',
category: 'basic',
filters: { brightness: 105, contrast: 115, saturation: 0, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 20, grain: 5, sepia: 0, invert: 0 },
},
{
id: 'vintage',
name: 'Vintage',
category: 'vintage',
filters: { brightness: 95, contrast: 90, saturation: 75, hue: 20, exposure: -5, vibrance: -10, highlights: -10, shadows: 15, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 30, grain: 15, sepia: 20, invert: 0 },
},
{
id: 'fade',
name: 'Fade',
category: 'vintage',
filters: { brightness: 110, contrast: 85, saturation: 80, hue: 0, exposure: 5, vibrance: -5, highlights: 10, shadows: 20, clarity: -10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 15, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'dramatic',
name: 'Dramatic',
category: 'cinematic',
filters: { brightness: 95, contrast: 130, saturation: 90, hue: 0, exposure: -5, vibrance: 10, highlights: -15, shadows: -10, clarity: 25, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 15, vignette: 25, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'moody',
name: 'Moody',
category: 'mood',
filters: { brightness: 90, contrast: 110, saturation: 85, hue: -10, exposure: -10, vibrance: 0, highlights: -20, shadows: 5, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 35, grain: 5, sepia: 0, invert: 0 },
},
{
id: 'bright',
name: 'Bright',
category: 'basic',
filters: { brightness: 120, contrast: 105, saturation: 105, hue: 0, exposure: 15, vibrance: 10, highlights: 10, shadows: 20, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'sepia',
name: 'Sepia',
category: 'vintage',
filters: { brightness: 105, contrast: 95, saturation: 40, hue: 35, exposure: 0, vibrance: -20, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 20, grain: 10, sepia: 50, invert: 0 },
},
{
id: 'cinematic',
name: 'Cinematic',
category: 'cinematic',
filters: { brightness: 95, contrast: 115, saturation: 95, hue: -5, exposure: 0, vibrance: 5, highlights: -10, shadows: 5, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 20, grain: 3, sepia: 0, invert: 0 },
},
{
id: 'pop',
name: 'Pop',
category: 'mood',
filters: { brightness: 110, contrast: 120, saturation: 140, hue: 5, exposure: 5, vibrance: 40, highlights: 5, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'matte',
name: 'Matte',
category: 'cinematic',
filters: { brightness: 105, contrast: 85, saturation: 90, hue: 0, exposure: 0, vibrance: -5, highlights: 5, shadows: 15, clarity: -5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 10, grain: 0, sepia: 0, invert: 0 },
},
{
id: 'retro',
name: 'Retro',
category: 'vintage',
filters: { brightness: 100, contrast: 95, saturation: 70, hue: 25, exposure: -5, vibrance: -15, highlights: -5, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 25, grain: 20, sepia: 15, invert: 0 },
},
{
id: 'punch',
name: 'Punch',
category: 'basic',
filters: { brightness: 100, contrast: 125, saturation: 115, hue: 0, exposure: 0, vibrance: 20, highlights: 0, shadows: -10, clarity: 20, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 20, vignette: 0, grain: 0, sepia: 0, invert: 0 },
},
];
function filtersMatch(a: Filter, b: Filter): boolean {
return (
a.brightness === b.brightness &&
a.contrast === b.contrast &&
a.saturation === b.saturation &&
a.hue === b.hue &&
a.exposure === b.exposure &&
a.vibrance === b.vibrance &&
a.highlights === b.highlights &&
a.shadows === b.shadows &&
a.clarity === b.clarity &&
a.blur === b.blur &&
a.blurType === b.blurType &&
a.blurAngle === b.blurAngle &&
a.sharpen === b.sharpen &&
a.vignette === b.vignette &&
a.grain === b.grain &&
a.sepia === b.sepia &&
a.invert === b.invert
);
}
function interpolateFilters(target: Filter, intensity: number): Filter {
const lerp = (defaultVal: number, targetVal: number) => defaultVal + (targetVal - defaultVal) * (intensity / 100);
return {
brightness: Math.round(lerp(100, target.brightness)),
contrast: Math.round(lerp(100, target.contrast)),
saturation: Math.round(lerp(100, target.saturation)),
hue: Math.round(lerp(0, target.hue)),
exposure: Math.round(lerp(0, target.exposure)),
vibrance: Math.round(lerp(0, target.vibrance)),
highlights: Math.round(lerp(0, target.highlights)),
shadows: Math.round(lerp(0, target.shadows)),
clarity: Math.round(lerp(0, target.clarity)),
blur: Math.round(lerp(0, target.blur)),
blurType: target.blurType,
blurAngle: Math.round(lerp(0, target.blurAngle)),
sharpen: Math.round(lerp(0, target.sharpen)),
vignette: Math.round(lerp(0, target.vignette)),
grain: Math.round(lerp(0, target.grain)),
sepia: Math.round(lerp(0, target.sepia)),
invert: Math.round(lerp(0, target.invert)),
};
}
export function FilterPresetsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [intensity, setIntensity] = useState(100);
const [activePresetId, setActivePresetId] = useState<string | null>(() => {
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<ImageLayer>(layer.id, { filters });
};
const handleIntensityChange = (newIntensity: number) => {
setIntensity(newIntensity);
if (currentPreset) {
const filters = interpolateFilters(currentPreset.filters, newIntensity);
updateLayer<ImageLayer>(layer.id, { filters });
}
};
const isOriginal = activePresetId === 'original' || filtersMatch(layer.filters, FILTER_PRESETS[0].filters);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Filters
</h4>
{!isOriginal && (
<button
onClick={() => handlePresetSelect(FILTER_PRESETS[0])}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{activePresetId && activePresetId !== 'original' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-foreground">Intensity</label>
<span className="text-[11px] font-mono text-muted-foreground">{intensity}%</span>
</div>
<input
type="range"
value={intensity}
min={0}
max={100}
onChange={(e) => 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"
/>
</div>
)}
<div className="grid grid-cols-4 gap-2">
{FILTER_PRESETS.map((preset) => {
const isActive = activePresetId === preset.id;
const previewStyle = getFilterPreviewStyle(preset.filters);
return (
<button
key={preset.id}
onClick={() => handlePresetSelect(preset)}
className={`relative group flex flex-col items-center gap-1 p-2 rounded-lg transition-all ${
isActive
? 'bg-primary/20 ring-2 ring-primary'
: 'bg-secondary/50 hover:bg-secondary'
}`}
>
<div
className="w-10 h-10 rounded-md bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center overflow-hidden"
style={previewStyle}
>
{preset.id === 'original' ? (
<Sparkles size={16} className="text-white/80" />
) : isActive ? (
<Check size={14} className="text-primary" />
) : null}
</div>
<span className={`text-[9px] font-medium truncate w-full text-center ${
isActive ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground'
}`}>
{preset.name}
</span>
</button>
);
})}
</div>
</div>
);
}
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,
};
}

View file

@ -0,0 +1,202 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { GradientMapStop } from '../../../types/adjustments';
import { DEFAULT_GRADIENT_MAP } from '../../../types/adjustments';
import { Paintbrush, RotateCcw, Plus, X } from 'lucide-react';
interface Props {
layer: Layer;
}
const GRADIENT_PRESETS = [
{ name: 'B&W', stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }] },
{ name: 'Sepia', stops: [{ position: 0, color: '#2b1810' }, { position: 0.5, color: '#8b5a2b' }, { position: 1, color: '#f5deb3' }] },
{ name: 'Duotone Blue', stops: [{ position: 0, color: '#001133' }, { position: 1, color: '#66ccff' }] },
{ name: 'Duotone Orange', stops: [{ position: 0, color: '#331100' }, { position: 1, color: '#ff9900' }] },
{ name: 'Sunset', stops: [{ position: 0, color: '#1a0533' }, { position: 0.5, color: '#ff6b35' }, { position: 1, color: '#f7c59f' }] },
];
export function GradientMapSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const gradientMap = layer.gradientMap;
const handleStopChange = (index: number, updates: Partial<GradientMapStop>) => {
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 (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paintbrush size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Gradient Map</span>
{gradientMap.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={gradientMap.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1 flex-wrap">
{GRADIENT_PRESETS.map((preset) => (
<button
key={preset.name}
onClick={() => applyPreset(preset)}
className="px-2 py-1 text-[9px] bg-secondary/50 hover:bg-secondary rounded text-muted-foreground hover:text-foreground transition-colors"
>
{preset.name}
</button>
))}
</div>
<button
onClick={resetGradientMap}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div
className="h-6 rounded border border-border"
style={{ background: gradientStyle }}
/>
<div className="space-y-2">
{gradientMap.stops.map((stop, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="color"
value={stop.color}
onChange={(e) => handleStopChange(index, { color: e.target.value })}
className="w-6 h-6 rounded border-none cursor-pointer"
/>
<input
type="range"
value={stop.position * 100}
min={0}
max={100}
onChange={(e) => 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"
/>
<span className="text-[10px] font-mono text-muted-foreground w-8">{Math.round(stop.position * 100)}%</span>
{gradientMap.stops.length > 2 && (
<button
onClick={() => removeStop(index)}
className="p-0.5 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
>
<X size={10} />
</button>
)}
</div>
))}
</div>
<button
onClick={addStop}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
<Plus size={10} /> Add Stop
</button>
<div className="flex gap-4 pt-1 border-t border-border/50">
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={gradientMap.reverse}
onChange={(e) => handleReverseChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Reverse
</label>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={gradientMap.dither}
onChange={(e) => handleDitherChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Dither
</label>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,176 @@
import { useUIStore } from '../../../stores/ui-store';
import { SquareStack, RotateCcw, X, Plus } from 'lucide-react';
const gradientTypes = [
{ id: 'linear', label: 'Linear' },
{ id: 'radial', label: 'Radial' },
{ id: 'angle', label: 'Angle' },
{ id: 'reflected', label: 'Reflected' },
{ id: 'diamond', label: 'Diamond' },
] as const;
export function GradientToolPanel() {
const { gradientSettings, setGradientSettings } = useUIStore();
const resetSettings = () => {
setGradientSettings({
type: 'linear',
colors: ['#000000', '#ffffff'],
opacity: 1,
reverse: false,
dither: true,
});
};
const updateColor = (index: number, color: string) => {
const newColors = [...gradientSettings.colors];
newColors[index] = color;
setGradientSettings({ colors: newColors });
};
const addColor = () => {
if (gradientSettings.colors.length >= 5) return;
const newColors = [...gradientSettings.colors, '#808080'];
setGradientSettings({ colors: newColors });
};
const removeColor = (index: number) => {
if (gradientSettings.colors.length <= 2) return;
const newColors = gradientSettings.colors.filter((_, i) => i !== index);
setGradientSettings({ colors: newColors });
};
const gradientStyle = `linear-gradient(to right, ${gradientSettings.colors.join(', ')})`;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SquareStack size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Gradient</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Click and drag on canvas to create gradient.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Type</span>
<div className="grid grid-cols-5 gap-1">
{gradientTypes.map((type) => (
<button
key={type.id}
onClick={() => setGradientSettings({ type: type.id })}
className={`px-1 py-1.5 text-[10px] rounded transition-colors ${
gradientSettings.type === type.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{type.label}
</button>
))}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Preview</span>
<div
className="h-6 rounded border border-border"
style={{ background: gradientStyle }}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Colors</span>
{gradientSettings.colors.length < 5 && (
<button
onClick={addColor}
className="p-0.5 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
>
<Plus size={12} />
</button>
)}
</div>
<div className="space-y-1.5">
{gradientSettings.colors.map((color, index) => (
<div key={index} className="flex items-center gap-2">
<input
type="color"
value={color}
onChange={(e) => updateColor(index, e.target.value)}
className="w-8 h-8 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => 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 && (
<button
onClick={() => removeColor(index)}
className="p-1 text-muted-foreground hover:text-destructive rounded hover:bg-secondary transition-colors"
>
<X size={12} />
</button>
)}
</div>
))}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(gradientSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={gradientSettings.opacity * 100}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={gradientSettings.reverse}
onChange={(e) => setGradientSettings({ reverse: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Reverse
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={gradientSettings.dither}
onChange={(e) => setGradientSettings({ dither: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Dither
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,117 @@
import { useUIStore } from '../../../stores/ui-store';
import { Bandage, RotateCcw } from 'lucide-react';
export function HealingBrushToolPanel() {
const { healingBrushSettings, setHealingBrushSettings } = useUIStore();
const resetSettings = () => {
setHealingBrushSettings({
size: 30,
hardness: 50,
mode: 'normal',
sourcePoint: null,
aligned: true,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bandage size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Healing Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Hold Alt/Option and click to set source, then paint to heal while matching texture and lighting.
</p>
{healingBrushSettings.sourcePoint && (
<div className="text-xs text-muted-foreground bg-secondary/50 p-2 rounded">
Source: ({Math.round(healingBrushSettings.sourcePoint.x)}, {Math.round(healingBrushSettings.sourcePoint.y)})
</div>
)}
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={healingBrushSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Hardness</span>
<span className="text-xs font-mono text-muted-foreground">{healingBrushSettings.hardness}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={healingBrushSettings.hardness}
onChange={(e) => 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"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
{(['normal', 'replace', 'multiply', 'screen'] as const).map((mode) => (
<button
key={mode}
onClick={() => setHealingBrushSettings({ mode })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
healingBrushSettings.mode === mode
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{mode}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={healingBrushSettings.aligned}
onChange={(e) => setHealingBrushSettings({ aligned: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Aligned
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,347 @@
import { useProjectStore } from '../../../stores/project-store';
import type { ImageLayer, Filter, BlurType } from '../../../types/project';
import { Sun, Contrast, Palette, Thermometer, Focus, Sparkles, CircleDot, Scan, Film, Minus, Move, Target, SunMedium, Vibrate, Sunrise, SunDim, Aperture } from 'lucide-react';
interface Props {
layer: ImageLayer;
}
interface AdjustmentSliderProps {
icon: React.ReactNode;
label: string;
value: number;
min: number;
max: number;
defaultValue: number;
onChange: (value: number) => void;
unit?: string;
}
function AdjustmentSlider({ icon, label, value, min, max, defaultValue, onChange, unit = '' }: AdjustmentSliderProps) {
const isModified = value !== defaultValue;
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">{icon}</span>
<label className="text-[11px] text-foreground font-medium">{label}</label>
</div>
<div className="flex items-center gap-1">
<span className={`text-[11px] font-mono ${isModified ? 'text-primary' : 'text-muted-foreground'}`}>
{value}{unit}
</span>
{isModified && (
<button
onClick={() => onChange(defaultValue)}
className="text-[9px] text-muted-foreground hover:text-foreground px-1 py-0.5 rounded hover:bg-secondary transition-colors"
>
Reset
</button>
)}
</div>
</div>
<div className="relative">
<input
type="range"
value={value}
min={min}
max={max}
onChange={(e) => 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%)`
}}
/>
</div>
</div>
);
}
export function ImageAdjustmentsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const handleFilterChange = (key: keyof Filter, value: number | BlurType) => {
updateLayer<ImageLayer>(layer.id, {
filters: { ...layer.filters, [key]: value },
});
};
const handleBlurTypeChange = (type: BlurType) => {
updateLayer<ImageLayer>(layer.id, {
filters: { ...layer.filters, blurType: type },
});
};
const resetAllFilters = () => {
updateLayer<ImageLayer>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Adjustments
</h4>
{hasModifications && (
<button
onClick={resetAllFilters}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset All
</button>
)}
</div>
<div className="space-y-4 p-3 bg-secondary/30 rounded-lg border border-border/50">
<AdjustmentSlider
icon={<Sun size={12} />}
label="Brightness"
value={layer.filters.brightness}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('brightness', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Contrast size={12} />}
label="Contrast"
value={layer.filters.contrast}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('contrast', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Palette size={12} />}
label="Saturation"
value={layer.filters.saturation}
min={0}
max={200}
defaultValue={100}
onChange={(v) => handleFilterChange('saturation', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Thermometer size={12} />}
label="Temperature"
value={layer.filters.hue}
min={-180}
max={180}
defaultValue={0}
onChange={(v) => handleFilterChange('hue', v)}
unit="°"
/>
<AdjustmentSlider
icon={<SunMedium size={12} />}
label="Exposure"
value={layer.filters.exposure}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('exposure', v)}
/>
<AdjustmentSlider
icon={<Vibrate size={12} />}
label="Vibrance"
value={layer.filters.vibrance}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('vibrance', v)}
/>
<AdjustmentSlider
icon={<Sunrise size={12} />}
label="Highlights"
value={layer.filters.highlights}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('highlights', v)}
/>
<AdjustmentSlider
icon={<SunDim size={12} />}
label="Shadows"
value={layer.filters.shadows}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('shadows', v)}
/>
<AdjustmentSlider
icon={<Aperture size={12} />}
label="Clarity"
value={layer.filters.clarity}
min={-100}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('clarity', v)}
/>
<AdjustmentSlider
icon={<Focus size={12} />}
label="Blur"
value={layer.filters.blur}
min={0}
max={50}
defaultValue={0}
onChange={(v) => handleFilterChange('blur', v)}
unit="px"
/>
{layer.filters.blur > 0 && (
<div className="space-y-2 pl-5 border-l-2 border-primary/30">
<div className="space-y-1.5">
<label className="text-[11px] text-foreground font-medium">Blur Type</label>
<div className="flex gap-1">
{([
{ type: 'gaussian' as BlurType, icon: <Focus size={12} />, label: 'Gaussian' },
{ type: 'motion' as BlurType, icon: <Move size={12} />, label: 'Motion' },
{ type: 'radial' as BlurType, icon: <Target size={12} />, label: 'Radial' },
]).map(({ type, icon, label }) => (
<button
key={type}
onClick={() => handleBlurTypeChange(type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded text-[10px] font-medium transition-all ${
layer.filters.blurType === type
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{icon}
{label}
</button>
))}
</div>
</div>
{layer.filters.blurType === 'motion' && (
<AdjustmentSlider
icon={<Move size={12} />}
label="Angle"
value={layer.filters.blurAngle}
min={0}
max={360}
defaultValue={0}
onChange={(v) => handleFilterChange('blurAngle', v)}
unit="°"
/>
)}
</div>
)}
<AdjustmentSlider
icon={<Sparkles size={12} />}
label="Sharpen"
value={layer.filters.sharpen}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('sharpen', v)}
unit="%"
/>
<AdjustmentSlider
icon={<CircleDot size={12} />}
label="Vignette"
value={layer.filters.vignette}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('vignette', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Scan size={12} />}
label="Grain"
value={layer.filters.grain}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('grain', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Film size={12} />}
label="Sepia"
value={layer.filters.sepia}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('sepia', v)}
unit="%"
/>
<AdjustmentSlider
icon={<Minus size={12} />}
label="Invert"
value={layer.filters.invert}
min={0}
max={100}
defaultValue={0}
onChange={(v) => handleFilterChange('invert', v)}
unit="%"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
import { Crop, ImageIcon } from 'lucide-react';
import type { ImageLayer } from '../../../types/project';
interface Props {
layer: ImageLayer;
}
export function ImageControlsSection({ layer }: Props) {
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Image
</h4>
<div className="p-3 bg-secondary/30 rounded-lg">
<div className="flex items-center gap-2 text-muted-foreground">
<ImageIcon size={14} />
<span className="text-[11px]">Source: {layer.sourceId ? 'Linked' : 'None'}</span>
</div>
{layer.cropRect && (
<div className="flex items-center gap-2 text-muted-foreground mt-2">
<Crop size={14} />
<span className="text-[11px]">
Cropped: {Math.round(layer.cropRect.width)} × {Math.round(layer.cropRect.height)}
</span>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,467 @@
import { memo, lazy, Suspense, useState, createContext, useContext, ReactNode, JSX } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useUIStore } from '../../../stores/ui-store';
import { TransformSection } from './TransformSection';
import { AlignmentSection } from './AlignmentSection';
import { AppearanceSection } from './AppearanceSection';
import { EffectsSection } from './EffectsSection';
import { ArtboardSection } from './ArtboardSection';
import { PenSettingsSection } from './PenSettingsSection';
import { ColorHarmonySection } from './ColorHarmonySection';
import { ChevronRight, Sliders, Palette, Wand2, Sparkles, Image as ImageIcon, Layers } from 'lucide-react';
import { ScrollArea } from '@openreel/ui';
import type { Layer, ImageLayer, TextLayer, ShapeLayer } from '../../../types/project';
import type { Tool } from '../../../stores/ui-store';
const TOOL_FOCUSED_TOOLS = new Set<Tool>([
'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 (
<div className="px-4 py-3">
<div className="h-4 w-24 animate-pulse bg-muted/40 rounded mb-3" />
<div className="space-y-2">
<div className="h-8 animate-pulse bg-muted/30 rounded" />
<div className="h-8 animate-pulse bg-muted/30 rounded" />
</div>
</div>
);
}
type AccordionContextType = {
openItems: string[];
toggle: (id: string) => void;
};
const AccordionContext = createContext<AccordionContextType | null>(null);
interface AccordionProps {
children: ReactNode;
defaultOpen?: string[];
}
function Accordion({ children, defaultOpen = [] }: AccordionProps) {
const [openItems, setOpenItems] = useState<string[]>(defaultOpen);
const toggle = (id: string) => {
setOpenItems(prev =>
prev.includes(id)
? prev.filter(item => item !== id)
: [...prev, id]
);
};
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="divide-y divide-border">{children}</div>
</AccordionContext.Provider>
);
}
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 (
<div>
<button
onClick={() => toggle(id)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
>
<ChevronRight
size={14}
className={`text-muted-foreground shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-90' : ''}`}
/>
{Icon && <Icon size={16} className="text-muted-foreground shrink-0" />}
<span className="text-sm font-medium text-foreground flex-1">{title}</span>
{badge !== undefined && badge > 0 && (
<span className="text-[10px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded-full">
{badge}
</span>
)}
</button>
{isOpen && (
<div className="pb-4">
{children}
</div>
)}
</div>
);
}
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 (
<Suspense fallback={<SectionLoader />}>
<SelectionToolsPanel />
</Suspense>
);
}
if (TRANSFORM_TOOLS.includes(tool)) {
return (
<Suspense fallback={<SectionLoader />}>
<TransformToolPanel />
</Suspense>
);
}
switch (tool) {
case 'pen':
return <PenSettingsSection />;
case 'brush':
return (
<Suspense fallback={<SectionLoader />}>
<BrushToolPanel />
</Suspense>
);
case 'eraser':
return (
<Suspense fallback={<SectionLoader />}>
<EraserToolPanel />
</Suspense>
);
case 'gradient':
return (
<Suspense fallback={<SectionLoader />}>
<GradientToolPanel />
</Suspense>
);
case 'dodge':
case 'burn':
return (
<Suspense fallback={<SectionLoader />}>
<DodgeBurnToolPanel />
</Suspense>
);
case 'sponge':
return (
<Suspense fallback={<SectionLoader />}>
<SpongeToolPanel />
</Suspense>
);
case 'blur':
case 'sharpen':
return (
<Suspense fallback={<SectionLoader />}>
<BlurSharpenToolPanel />
</Suspense>
);
case 'smudge':
return (
<Suspense fallback={<SectionLoader />}>
<SmudgeToolPanel />
</Suspense>
);
case 'clone-stamp':
return (
<Suspense fallback={<SectionLoader />}>
<CloneStampToolPanel />
</Suspense>
);
case 'healing-brush':
return (
<Suspense fallback={<SectionLoader />}>
<HealingBrushToolPanel />
</Suspense>
);
case 'spot-healing':
return (
<Suspense fallback={<SectionLoader />}>
<SpotHealingToolPanel />
</Suspense>
);
case 'liquify':
return (
<Suspense fallback={<SectionLoader />}>
<LiquifyToolPanel />
</Suspense>
);
case 'paint-bucket':
return (
<Suspense fallback={<SectionLoader />}>
<PaintBucketToolPanel />
</Suspense>
);
case 'crop':
if (imageLayer) {
return (
<Suspense fallback={<SectionLoader />}>
<CropSection layer={imageLayer} />
</Suspense>
);
}
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 (
<ScrollArea className="h-full">
<div className="p-4">
{toolPanel}
</div>
</ScrollArea>
);
}
}
if (selectedLayers.length > 1) {
return (
<ScrollArea className="h-full">
<div className="p-4">
<div className="flex items-center gap-2 mb-6">
<div className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Layers size={16} className="text-primary" />
</div>
<div>
<h3 className="text-sm font-semibold text-foreground">
{selectedLayers.length} layers
</h3>
<p className="text-xs text-muted-foreground">Multiple selection</p>
</div>
</div>
<AlignmentSection layers={selectedLayers} />
</div>
</ScrollArea>
);
}
if (!singleLayer) {
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
if (artboard) {
return (
<ScrollArea className="h-full">
<div className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-4">Artboard</h3>
<ArtboardSection artboard={artboard} />
</div>
</ScrollArea>
);
}
return (
<div className="h-full flex items-center justify-center p-6">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-muted/50 flex items-center justify-center">
<Layers size={20} className="text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Select a layer to view<br />and edit its properties
</p>
</div>
</div>
);
}
const getLayerIcon = () => {
switch (singleLayer.type) {
case 'image': return ImageIcon;
case 'text': return () => <span className="text-sm font-bold">T</span>;
case 'shape': return () => <span className="text-sm"></span>;
default: return Layers;
}
};
const LayerIcon = getLayerIcon();
return (
<ScrollArea className="h-full">
<div className="pb-8">
<div className="px-4 py-4 border-b border-border bg-card/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-muted/50 flex items-center justify-center shrink-0">
<LayerIcon size={18} className="text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-foreground truncate">
{singleLayer.name}
</h3>
<p className="text-xs text-muted-foreground capitalize">{singleLayer.type} layer</p>
</div>
</div>
</div>
<Accordion defaultOpen={['transform', 'appearance', 'quick-filters', 'basic-adjustments']}>
<AccordionItem id="transform" icon={Sliders} title="Transform & Position">
<div className="px-4 space-y-4">
<TransformSection layer={singleLayer} />
<div className="pt-2">
<AlignmentSection layers={[singleLayer]} />
</div>
</div>
</AccordionItem>
<AccordionItem id="appearance" icon={Palette} title="Appearance">
<div className="px-4">
<AppearanceSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="effects" icon={Sparkles} title="Effects">
<EffectsSection layer={singleLayer} />
</AccordionItem>
{singleLayer.type === 'image' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="image-controls" icon={ImageIcon} title="Image Controls">
<div className="px-4 space-y-4">
<ImageControlsSection layer={singleLayer as ImageLayer} />
<CropSection layer={singleLayer as ImageLayer} />
<BackgroundRemovalSection layer={singleLayer as ImageLayer} />
</div>
</AccordionItem>
<AccordionItem id="quick-filters" icon={Wand2} title="Quick Filters">
<FilterPresetsSection layer={singleLayer as ImageLayer} />
</AccordionItem>
<AccordionItem id="basic-adjustments" icon={Sliders} title="Basic Adjustments">
<ImageAdjustmentsSection layer={singleLayer as ImageLayer} />
</AccordionItem>
<AccordionItem id="tonal" icon={Sliders} title="Tonal Adjustments">
<div className="space-y-0">
<LevelsSection layer={singleLayer} />
<CurvesSection layer={singleLayer} />
<PosterizeSection layer={singleLayer} />
<ThresholdSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="color" icon={Palette} title="Color Adjustments">
<div className="space-y-0">
<ColorBalanceSection layer={singleLayer} />
<SelectiveColorSection layer={singleLayer} />
<PhotoFilterSection layer={singleLayer} />
<ChannelMixerSection layer={singleLayer} />
<GradientMapSection layer={singleLayer} />
<BlackWhiteSection layer={singleLayer} />
</div>
</AccordionItem>
<AccordionItem id="mask" icon={Layers} title="Mask">
<MaskSection layer={singleLayer} />
</AccordionItem>
</Suspense>
)}
{singleLayer.type === 'text' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="text-settings" title="Text Settings">
<div className="px-4">
<TextSection layer={singleLayer as TextLayer} />
</div>
</AccordionItem>
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
<div className="px-4">
<ColorHarmonySection
baseColor={(singleLayer as TextLayer).style.color}
onColorSelect={(color) => {
useProjectStore.getState().updateLayer<TextLayer>(singleLayer.id, {
style: { ...(singleLayer as TextLayer).style, color },
});
}}
/>
</div>
</AccordionItem>
</Suspense>
)}
{singleLayer.type === 'shape' && (
<Suspense fallback={<SectionLoader />}>
<AccordionItem id="shape-settings" title="Shape Settings">
<div className="px-4">
<ShapeSection layer={singleLayer as ShapeLayer} />
</div>
</AccordionItem>
{(singleLayer as ShapeLayer).shapeStyle.fill && (
<AccordionItem id="color-harmony" icon={Palette} title="Color Harmony">
<div className="px-4">
<ColorHarmonySection
baseColor={(singleLayer as ShapeLayer).shapeStyle.fill!}
onColorSelect={(color) => {
useProjectStore.getState().updateLayer<ShapeLayer>(singleLayer.id, {
shapeStyle: { ...(singleLayer as ShapeLayer).shapeStyle, fill: color },
});
}}
/>
</div>
</AccordionItem>
)}
</Suspense>
)}
</Accordion>
</div>
</ScrollArea>
);
}
export const Inspector = memo(InspectorContent);

View file

@ -0,0 +1,213 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { LevelsChannel } from '../../../types/adjustments';
import { DEFAULT_LEVELS } from '../../../types/adjustments';
import { Activity, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ChannelType = 'master' | 'red' | 'green' | 'blue';
interface LevelsSliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function LevelsSlider({ label, value, min, max, step = 1, onChange }: LevelsSliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">{value.toFixed(step < 1 ? 2 : 0)}</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => 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%)`
}}
/>
</div>
);
}
export function LevelsSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeChannel, setActiveChannel] = useState<ChannelType>('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<ChannelType, string> = {
master: 'bg-foreground',
red: 'bg-red-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
};
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Activity size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Levels</span>
{levels.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<div className="flex items-center gap-1">
<input
type="checkbox"
checked={levels.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</div>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{(['master', 'red', 'green', 'blue'] as ChannelType[]).map((channel) => (
<button
key={channel}
onClick={() => setActiveChannel(channel)}
className={`px-2 py-1 text-[10px] rounded transition-colors ${
activeChannel === channel
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1 ${channelColors[channel]}`} />
{channel.charAt(0).toUpperCase()}
</button>
))}
</div>
<button
onClick={resetLevels}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset Levels"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2.5 pt-1">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Input Levels</span>
<div className="flex gap-2">
<div className="flex-1">
<LevelsSlider
label="Black"
value={currentChannel.inputBlack}
min={0}
max={255}
onChange={(v) => handleChannelChange('inputBlack', v)}
/>
</div>
<div className="flex-1">
<LevelsSlider
label="White"
value={currentChannel.inputWhite}
min={0}
max={255}
onChange={(v) => handleChannelChange('inputWhite', v)}
/>
</div>
</div>
</div>
<LevelsSlider
label="Gamma"
value={currentChannel.gamma}
min={0.1}
max={10}
step={0.01}
onChange={(v) => handleChannelChange('gamma', v)}
/>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Output Levels</span>
<div className="flex gap-2">
<div className="flex-1">
<LevelsSlider
label="Black"
value={currentChannel.outputBlack}
min={0}
max={255}
onChange={(v) => handleChannelChange('outputBlack', v)}
/>
</div>
<div className="flex-1">
<LevelsSlider
label="White"
value={currentChannel.outputWhite}
min={0}
max={255}
onChange={(v) => handleChannelChange('outputWhite', v)}
/>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,152 @@
import { useUIStore } from '../../../stores/ui-store';
import { Waves, RotateCcw, ArrowRight, Undo2, Sparkles, RotateCw, RotateCcw as Counterclockwise, Minus, Plus, ArrowLeft, Snowflake, Flame } from 'lucide-react';
const liquifyTools = [
{ id: 'forward-warp', label: 'Forward Warp', icon: ArrowRight },
{ id: 'reconstruct', label: 'Reconstruct', icon: Undo2 },
{ id: 'smooth', label: 'Smooth', icon: Sparkles },
{ id: 'twirl-clockwise', label: 'Twirl CW', icon: RotateCw },
{ id: 'twirl-counterclockwise', label: 'Twirl CCW', icon: Counterclockwise },
{ id: 'pucker', label: 'Pucker', icon: Minus },
{ id: 'bloat', label: 'Bloat', icon: Plus },
{ id: 'push-left', label: 'Push Left', icon: ArrowLeft },
{ id: 'freeze', label: 'Freeze', icon: Snowflake },
{ id: 'thaw', label: 'Thaw', icon: Flame },
] as const;
export function LiquifyToolPanel() {
const { liquifySettings, setLiquifySettings } = useUIStore();
const resetSettings = () => {
setLiquifySettings({
brushSize: 100,
brushDensity: 50,
brushPressure: 100,
brushRate: 80,
tool: 'forward-warp',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Waves size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Liquify</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Tool</span>
<div className="grid grid-cols-5 gap-1">
{liquifyTools.map((tool) => {
const Icon = tool.icon;
return (
<button
key={tool.id}
onClick={() => setLiquifySettings({ tool: tool.id })}
className={`p-2 rounded transition-colors ${
liquifySettings.tool === tool.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={tool.label}
>
<Icon size={14} />
</button>
);
})}
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Brush Size</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushSize}px</span>
</div>
<input
type="range"
min={1}
max={1500}
value={liquifySettings.brushSize}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Density</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushDensity}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushDensity}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Pressure</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushPressure}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushPressure}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Rate</span>
<span className="text-xs font-mono text-muted-foreground">{liquifySettings.brushRate}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={liquifySettings.brushRate}
onChange={(e) => 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"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,293 @@
import { useProjectStore } from '../../../stores/project-store';
import { useSelectionStore } from '../../../stores/selection-store';
import type { Layer } from '../../../types/project';
import type { LayerMask } from '../../../types/mask';
import {
Circle,
Eye,
EyeOff,
Link,
Unlink,
Trash2,
RotateCcw,
Plus,
Download,
} from 'lucide-react';
interface Props {
layer: Layer;
}
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}
{label === 'Density' || label === 'Feather' ? '%' : 'px'}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => 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%)`,
}}
/>
</div>
);
}
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 (
<div className="border-b border-border">
<div className="px-3 py-2">
<span className="text-xs font-medium">Masks</span>
</div>
<div className="px-3 pb-3 space-y-3">
{!hasMask ? (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground">
{hasSelection
? 'Create mask from current selection'
: 'Add a mask to control layer visibility'}
</p>
<div className="flex gap-1.5">
<button
onClick={() => handleAddMask(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Plus size={10} />
Reveal All
</button>
<button
onClick={() => handleAddMask(false)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Plus size={10} />
Hide All
</button>
</div>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2 p-2 rounded bg-secondary/50">
<div className="w-8 h-8 rounded bg-gradient-to-br from-white to-black border border-border" />
<div className="flex-1 min-w-0">
<p className="text-[10px] font-medium truncate">
{mask.type === 'pixel' ? 'Pixel Mask' : 'Vector Mask'}
</p>
<p className="text-[9px] text-muted-foreground">
{mask.enabled ? 'Enabled' : 'Disabled'}
{mask.invert ? ' • Inverted' : ''}
</p>
</div>
</div>
<div className="flex gap-1">
<button
onClick={handleToggleMaskEnabled}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.enabled
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.enabled ? 'Disable Mask' : 'Enable Mask'}
>
{mask.enabled ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<button
onClick={handleToggleMaskLinked}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.linked
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.linked ? 'Unlink Mask' : 'Link Mask'}
>
{mask.linked ? <Link size={12} /> : <Unlink size={12} />}
</button>
<button
onClick={handleToggleMaskInvert}
className={`flex-1 p-1.5 rounded transition-colors ${
mask.invert
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
title={mask.invert ? 'Remove Invert' : 'Invert Mask'}
>
<RotateCcw size={12} />
</button>
<button
onClick={handleDeleteMask}
className="flex-1 p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
title="Delete Mask"
>
<Trash2 size={12} />
</button>
</div>
<Slider
label="Density"
value={mask.density}
min={0}
max={100}
onChange={handleDensityChange}
/>
<Slider
label="Feather"
value={mask.feather}
min={0}
max={250}
onChange={handleFeatherChange}
/>
{hasSelection && (
<div className="pt-2 border-t border-border space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">
Apply Selection
</span>
<div className="flex gap-1.5">
<button
onClick={() => {}}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Add to Mask
</button>
<button
onClick={() => {}}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Subtract
</button>
</div>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-border">
<button
onClick={handleToggleClippingMask}
className={`w-full flex items-center gap-2 px-2 py-1.5 text-[10px] rounded transition-colors ${
layer.clippingMask
? 'bg-primary/10 text-primary'
: 'bg-secondary hover:bg-secondary/80'
}`}
>
<Circle size={10} className={layer.clippingMask ? 'fill-primary' : ''} />
<span>{layer.clippingMask ? 'Release Clipping Mask' : 'Create Clipping Mask'}</span>
</button>
</div>
<div className="flex gap-1.5">
<button
onClick={() => {}}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
title="Load mask from selection"
>
<Download size={10} />
Load Selection
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,120 @@
import { useUIStore } from '../../../stores/ui-store';
import { PaintBucket, RotateCcw } from 'lucide-react';
export function PaintBucketToolPanel() {
const { paintBucketSettings, setPaintBucketSettings, brushSettings, setBrushSettings } = useUIStore();
const resetSettings = () => {
setPaintBucketSettings({
color: '#000000',
tolerance: 32,
contiguous: true,
antiAlias: true,
opacity: 1,
fillType: 'foreground',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PaintBucket size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Paint Bucket</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Click on canvas to fill area with color.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Fill Color</span>
<div className="flex items-center gap-2">
<input
type="color"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="w-10 h-10 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={brushSettings.color}
onChange={(e) => setBrushSettings({ color: e.target.value })}
className="flex-1 px-2 py-1.5 text-xs font-mono bg-secondary/50 border border-border rounded"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Tolerance</span>
<span className="text-xs font-mono text-muted-foreground">{paintBucketSettings.tolerance}</span>
</div>
<input
type="range"
min={0}
max={255}
value={paintBucketSettings.tolerance}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Opacity</span>
<span className="text-xs font-mono text-muted-foreground">{Math.round(paintBucketSettings.opacity * 100)}%</span>
</div>
<input
type="range"
min={0}
max={100}
value={paintBucketSettings.opacity * 100}
onChange={(e) => 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"
/>
</div>
<div className="space-y-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={paintBucketSettings.contiguous}
onChange={(e) => setPaintBucketSettings({ contiguous: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Contiguous
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={paintBucketSettings.antiAlias}
onChange={(e) => setPaintBucketSettings({ antiAlias: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Anti-alias
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import { useUIStore } from '../../../stores/ui-store';
import { Pencil } from 'lucide-react';
export function PenSettingsSection() {
const { penSettings, setPenSettings } = useUIStore();
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Pencil size={16} className="text-primary" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Pen Settings
</h4>
</div>
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Color</label>
<input
type="color"
value={penSettings.color}
onChange={(e) => setPenSettings({ color: e.target.value })}
className="w-8 h-6 rounded border border-border cursor-pointer"
/>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Width</label>
<span className="text-[11px] font-mono text-muted-foreground">{penSettings.width}px</span>
</div>
<input
type="range"
value={penSettings.width}
min={1}
max={50}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-[11px] text-foreground font-medium">Opacity</label>
<span className="text-[11px] font-mono text-muted-foreground">{Math.round(penSettings.opacity * 100)}%</span>
</div>
<input
type="range"
value={penSettings.opacity}
min={0.1}
max={1}
step={0.1}
onChange={(e) => 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"
/>
</div>
</div>
<p className="text-[10px] text-muted-foreground">
Click and drag on the canvas to draw
</p>
</div>
);
}

View file

@ -0,0 +1,179 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { PhotoFilterAdjustment } from '../../../types/adjustments';
import { DEFAULT_PHOTO_FILTER } from '../../../types/adjustments';
import { PHOTO_FILTER_COLORS } from '../../../adjustments/photo-filter';
import { SunDim, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
const FILTER_OPTIONS = [
{ id: 'warming-85', label: 'Warming (85)', group: 'Warming' },
{ id: 'warming-81', label: 'Warming (81)', group: 'Warming' },
{ id: 'cooling-80', label: 'Cooling (80)', group: 'Cooling' },
{ id: 'cooling-82', label: 'Cooling (82)', group: 'Cooling' },
{ id: 'custom', label: 'Custom Color', group: 'Custom' },
] as const;
type FilterType = typeof FILTER_OPTIONS[number]['id'];
export function PhotoFilterSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const photoFilter = layer.photoFilter;
const handleFilterChange = (filter: FilterType) => {
const color = filter === 'custom' ? photoFilter.color : (PHOTO_FILTER_COLORS[filter as keyof typeof PHOTO_FILTER_COLORS] ?? photoFilter.color);
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
filter,
color,
},
});
};
const handleDensityChange = (density: number) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
density,
},
});
};
const handleColorChange = (color: string) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
filter: 'custom',
color,
} as PhotoFilterAdjustment,
});
};
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
preserveLuminosity,
},
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
photoFilter: {
...photoFilter,
enabled,
},
});
};
const resetPhotoFilter = () => {
updateLayer(layer.id, {
photoFilter: { ...DEFAULT_PHOTO_FILTER },
});
};
const densityPercentage = photoFilter.density;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<SunDim size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Photo Filter</span>
{photoFilter.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={photoFilter.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<select
value={photoFilter.filter}
onChange={(e) => handleFilterChange(e.target.value as FilterType)}
className="text-[10px] bg-secondary border-none rounded px-2 py-1 text-foreground flex-1"
>
{FILTER_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>{option.label}</option>
))}
</select>
<button
onClick={resetPhotoFilter}
className="p-1 ml-2 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">Color</span>
<input
type="color"
value={photoFilter.color}
onChange={(e) => handleColorChange(e.target.value)}
className="w-6 h-6 rounded border-none cursor-pointer"
/>
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.color}</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Density</span>
<span className="text-[10px] font-mono text-muted-foreground">{photoFilter.density}%</span>
</div>
<input
type="range"
value={photoFilter.density}
min={0}
max={100}
onChange={(e) => 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%)`
}}
/>
</div>
<label className="flex items-center gap-2 text-[10px] text-muted-foreground">
<input
type="checkbox"
checked={photoFilter.preserveLuminosity}
onChange={(e) => handlePreserveLuminosityChange(e.target.checked)}
className="w-3 h-3 rounded border-border"
/>
Preserve Luminosity
</label>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import { DEFAULT_POSTERIZE } from '../../../types/adjustments';
import { Layers, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
export function PosterizeSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const posterize = layer.posterize;
const handleLevelsChange = (levels: number) => {
updateLayer(layer.id, {
posterize: { ...posterize, levels },
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
posterize: { ...posterize, enabled },
});
};
const resetPosterize = () => {
updateLayer(layer.id, {
posterize: { ...DEFAULT_POSTERIZE },
});
};
const percentage = ((posterize.levels - 2) / 253) * 100;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Layers size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Posterize</span>
{posterize.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={posterize.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Levels</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground">{posterize.levels}</span>
<button
onClick={resetPosterize}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
</div>
<input
type="range"
value={posterize.levels}
min={2}
max={255}
onChange={(e) => 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%)`
}}
/>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>2</span>
<span>255</span>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,324 @@
import { useState } from 'react';
import { useUIStore } from '../../../stores/ui-store';
import { useSelectionStore } from '../../../stores/selection-store';
import { useProjectStore } from '../../../stores/project-store';
import {
Square,
Circle,
Lasso,
Pentagon,
Wand2,
Plus,
Minus,
BoxSelect,
Trash2,
RotateCcw,
Download,
Upload,
ChevronDown,
X,
} from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 1 : 0)}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => 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%)`,
}}
/>
</div>
);
}
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 (
<div className="border-b border-border">
<div className="px-3 py-2">
<span className="text-xs font-medium">Selection Tools</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="flex flex-wrap gap-1">
{selectionTools.map((tool) => (
<button
key={tool.id}
onClick={() => setActiveTool(tool.id)}
className={`p-1.5 rounded transition-colors ${
activeTool === tool.id
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={tool.label}
>
<tool.icon size={14} />
</button>
))}
</div>
{isSelectionTool && (
<>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
<div className="flex gap-1">
{selectionModes.map((mode) => (
<button
key={mode.id}
onClick={() => setSelectionToolSettings({ mode: mode.id })}
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
selectionToolSettings.mode === mode.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
{mode.label}
</button>
))}
</div>
</div>
<Slider
label="Feather"
value={selectionToolSettings.feather}
min={0}
max={100}
onChange={(v) => setSelectionToolSettings({ feather: v })}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={selectionToolSettings.antiAlias}
onChange={(e) => setSelectionToolSettings({ antiAlias: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Anti-alias</span>
</label>
{activeTool === 'magic-wand' && (
<div className="space-y-2.5 pt-2 border-t border-border">
<Slider
label="Tolerance"
value={magicWandSettings.tolerance}
min={0}
max={255}
onChange={(v) => setMagicWandSettings({ tolerance: v })}
/>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={magicWandSettings.contiguous}
onChange={(e) => setMagicWandSettings({ contiguous: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Contiguous</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={magicWandSettings.sampleAllLayers}
onChange={(e) => setMagicWandSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
<span className="text-[10px] text-muted-foreground">Sample All Layers</span>
</label>
</div>
)}
</>
)}
{hasSelection && (
<div className="space-y-2.5 pt-2 border-t border-border">
<span className="text-[10px] text-muted-foreground font-medium">Selection Actions</span>
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={() => invertSelection(canvasBounds)}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<RotateCcw size={10} />
Invert
</button>
<button
onClick={() => clearSelection()}
className="flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Trash2 size={10} />
Deselect
</button>
</div>
<div className="space-y-1.5">
<div className="flex gap-1.5">
<button
onClick={() => expandSelection(1)}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Expand
</button>
<button
onClick={() => contractSelection(1)}
className="flex-1 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Contract
</button>
</div>
<button
onClick={() => featherSelection(selectionToolSettings.feather || 5)}
className="w-full px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
Feather ({selectionToolSettings.feather || 5}px)
</button>
</div>
<div className="flex gap-1.5">
<button
onClick={() => saveSelection(`Selection ${savedSelections.length + 1}`)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
title="Save Selection"
>
<Download size={10} />
Save
</button>
<div className="flex-1 relative">
<button
onClick={() => setShowLoadMenu(!showLoadMenu)}
disabled={savedSelections.length === 0}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Load Selection"
>
<Upload size={10} />
Load
{savedSelections.length > 0 && (
<ChevronDown size={8} className={`transition-transform ${showLoadMenu ? 'rotate-180' : ''}`} />
)}
</button>
{showLoadMenu && savedSelections.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-10 max-h-32 overflow-y-auto">
{savedSelections.map((sel, idx) => (
<div
key={sel.id}
className="flex items-center justify-between px-2 py-1.5 hover:bg-secondary/50 group"
>
<button
onClick={() => {
loadSelection(sel.id);
setShowLoadMenu(false);
}}
className="flex-1 text-left text-[10px] text-foreground truncate"
>
Selection {idx + 1}
</button>
<button
onClick={(e) => {
e.stopPropagation();
deleteSelection(sel.id);
}}
className="p-0.5 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,175 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import type { SelectiveColorValues, SelectiveColorAdjustment } from '../../../types/adjustments';
import { DEFAULT_SELECTIVE_COLOR } from '../../../types/adjustments';
import { Palette, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
type ColorRange = 'reds' | 'yellows' | 'greens' | 'cyans' | 'blues' | 'magentas' | 'whites' | 'neutrals' | 'blacks';
const COLOR_RANGES: { id: ColorRange; label: string; color: string }[] = [
{ id: 'reds', label: 'Reds', color: 'bg-red-500' },
{ id: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
{ id: 'greens', label: 'Greens', color: 'bg-green-500' },
{ id: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
{ id: 'blues', label: 'Blues', color: 'bg-blue-500' },
{ id: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
{ id: 'whites', label: 'Whites', color: 'bg-white border border-border' },
{ id: 'neutrals', label: 'Neutrals', color: 'bg-gray-500' },
{ id: 'blacks', label: 'Blacks', color: 'bg-gray-900' },
];
function ColorSlider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
const percentage = ((value + 100) / 200) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">{value}%</span>
</div>
<input
type="range"
value={value}
min={-100}
max={100}
onChange={(e) => 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%)`
}}
/>
</div>
);
}
export function SelectiveColorSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [activeRange, setActiveRange] = useState<ColorRange>('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 (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Selective Color</span>
{selectiveColor.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={selectiveColor.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{COLOR_RANGES.map((range) => (
<button
key={range.id}
onClick={() => setActiveRange(range.id)}
className={`w-5 h-5 rounded transition-all ${range.color} ${
activeRange === range.id ? 'ring-2 ring-primary ring-offset-1 ring-offset-background' : ''
}`}
title={range.label}
/>
))}
</div>
<button
onClick={resetSelectiveColor}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
<div className="space-y-2">
<ColorSlider label="Cyan" value={currentRange.cyan} onChange={(v) => handleValueChange('cyan', v)} />
<ColorSlider label="Magenta" value={currentRange.magenta} onChange={(v) => handleValueChange('magenta', v)} />
<ColorSlider label="Yellow" value={currentRange.yellow} onChange={(v) => handleValueChange('yellow', v)} />
<ColorSlider label="Black" value={currentRange.black} onChange={(v) => handleValueChange('black', v)} />
</div>
<div className="flex gap-1 pt-1">
{(['relative', 'absolute'] as const).map((method) => (
<button
key={method}
onClick={() => handleMethodChange(method)}
className={`flex-1 px-2 py-1 text-[10px] rounded transition-colors ${
selectiveColor.method === method
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{method.charAt(0).toUpperCase() + method.slice(1)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,524 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { ShapeLayer, ShapeStyle, Gradient, FillType, StrokeDashType, NoiseFill } from '../../../types/project';
import { DEFAULT_NOISE_FILL } from '../../../types/project';
import { Slider } from '@openreel/ui';
import { GradientPicker } from '../../ui/GradientPicker';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui';
import { ChevronDown, Link, Unlink } from 'lucide-react';
const DASH_PATTERNS: { value: StrokeDashType; label: string; preview: string }[] = [
{ value: 'solid', label: 'Solid', preview: '━━━━━━' },
{ value: 'dashed', label: 'Dashed', preview: '─ ─ ─ ─' },
{ value: 'dotted', label: 'Dotted', preview: '· · · · ·' },
{ value: 'dash-dot', label: 'Dash-Dot', preview: '─ · ─ ·' },
{ value: 'long-dash', label: 'Long Dash', preview: '── ── ──' },
];
interface Props {
layer: ShapeLayer;
}
export function ShapeSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isFillOpen, setIsFillOpen] = useState(true);
const [isStrokeOpen, setIsStrokeOpen] = useState(false);
const handleStyleChange = (updates: Partial<ShapeStyle>) => {
updateLayer<ShapeLayer>(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<NoiseFill>) => {
handleStyleChange({
noise: { ...(layer.shapeStyle.noise ?? DEFAULT_NOISE_FILL), ...updates },
});
};
const handleGradientChange = (gradient: Gradient) => {
handleStyleChange({ gradient });
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Shape
</h4>
<Collapsible open={isFillOpen} onOpenChange={setIsFillOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<span className="text-xs font-medium">Fill</span>
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded border border-input"
style={{
backgroundColor: layer.shapeStyle.fillType === 'solid' ? (layer.shapeStyle.fill ?? 'transparent') : undefined,
background: layer.shapeStyle.fillType === 'gradient' && layer.shapeStyle.gradient
? layer.shapeStyle.gradient.type === 'linear'
? `linear-gradient(${layer.shapeStyle.gradient.angle}deg, ${layer.shapeStyle.gradient.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`
: `radial-gradient(circle, ${layer.shapeStyle.gradient.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`
: undefined,
}}
/>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isFillOpen ? 'rotate-180' : ''}`} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div className="grid grid-cols-3 gap-1">
<button
onClick={() => handleFillTypeChange('solid')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'solid'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Solid
</button>
<button
onClick={() => handleFillTypeChange('gradient')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'gradient'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Gradient
</button>
<button
onClick={() => handleFillTypeChange('noise')}
className={`px-2 py-1.5 text-xs rounded-md transition-colors ${
layer.shapeStyle.fillType === 'noise'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Noise
</button>
</div>
{layer.shapeStyle.fillType === 'solid' && (
<div>
<div className="flex items-center gap-2">
<button
onClick={() => handleStyleChange({ fill: layer.shapeStyle.fill ? null : '#3b82f6' })}
className={`w-8 h-8 rounded border border-input flex items-center justify-center ${
layer.shapeStyle.fill ? '' : 'bg-background'
}`}
style={{ backgroundColor: layer.shapeStyle.fill ?? undefined }}
title={layer.shapeStyle.fill ? 'Remove fill' : 'Add fill'}
>
{!layer.shapeStyle.fill && (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{layer.shapeStyle.fill && (
<>
<input
type="color"
value={layer.shapeStyle.fill}
onChange={(e) => handleStyleChange({ fill: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.fill}
onChange={(e) => 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"
/>
</>
)}
</div>
</div>
)}
{layer.shapeStyle.fillType === 'gradient' && (
<GradientPicker
value={layer.shapeStyle.gradient}
onChange={handleGradientChange}
/>
)}
{layer.shapeStyle.fillType === 'noise' && (
<div className="space-y-3">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Base Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shapeStyle.noise?.baseColor ?? DEFAULT_NOISE_FILL.baseColor}
onChange={(e) => handleNoiseChange({ baseColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.noise?.baseColor ?? DEFAULT_NOISE_FILL.baseColor}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Noise Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={layer.shapeStyle.noise?.noiseColor ?? DEFAULT_NOISE_FILL.noiseColor}
onChange={(e) => handleNoiseChange({ noiseColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.noise?.noiseColor ?? DEFAULT_NOISE_FILL.noiseColor}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Density</label>
<span className="text-[10px] text-muted-foreground">{Math.round((layer.shapeStyle.noise?.density ?? 0.5) * 100)}%</span>
</div>
<Slider
value={[(layer.shapeStyle.noise?.density ?? 0.5) * 100]}
onValueChange={([density]) => handleNoiseChange({ density: density / 100 })}
min={5}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Grain Size</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.noise?.size ?? 2}px</span>
</div>
<Slider
value={[layer.shapeStyle.noise?.size ?? 2]}
onValueChange={([size]) => handleNoiseChange({ size })}
min={1}
max={10}
step={1}
/>
</div>
</div>
)}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Opacity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.shapeStyle.fillOpacity * 100)}%</span>
</div>
<Slider
value={[layer.shapeStyle.fillOpacity * 100]}
onValueChange={([opacity]) => handleStyleChange({ fillOpacity: opacity / 100 })}
min={0}
max={100}
step={1}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
<Collapsible open={isStrokeOpen} onOpenChange={setIsStrokeOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<span className="text-xs font-medium">Stroke</span>
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded border-2"
style={{ borderColor: layer.shapeStyle.stroke ?? '#71717a' }}
/>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isStrokeOpen ? 'rotate-180' : ''}`} />
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-3 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
<div className="flex items-center gap-2">
<button
onClick={() => handleStyleChange({ stroke: layer.shapeStyle.stroke ? null : '#000000' })}
className={`w-8 h-8 rounded border-2 flex items-center justify-center ${
layer.shapeStyle.stroke ? '' : 'border-input bg-background'
}`}
style={{ borderColor: layer.shapeStyle.stroke ?? undefined }}
title={layer.shapeStyle.stroke ? 'Remove stroke' : 'Add stroke'}
>
{!layer.shapeStyle.stroke && (
<span className="text-xs text-muted-foreground"></span>
)}
</button>
{layer.shapeStyle.stroke && (
<>
<input
type="color"
value={layer.shapeStyle.stroke}
onChange={(e) => handleStyleChange({ stroke: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.shapeStyle.stroke}
onChange={(e) => 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"
/>
</>
)}
</div>
{layer.shapeStyle.stroke && (
<>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.strokeWidth}px</span>
</div>
<Slider
value={[layer.shapeStyle.strokeWidth]}
onValueChange={([width]) => handleStyleChange({ strokeWidth: width })}
min={1}
max={20}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Opacity</label>
<span className="text-[10px] text-muted-foreground">{Math.round(layer.shapeStyle.strokeOpacity * 100)}%</span>
</div>
<Slider
value={[layer.shapeStyle.strokeOpacity * 100]}
onValueChange={([opacity]) => handleStyleChange({ strokeOpacity: opacity / 100 })}
min={0}
max={100}
step={1}
/>
</div>
<div>
<label className="text-[10px] text-muted-foreground mb-1.5 block">Dash Pattern</label>
<div className="grid grid-cols-1 gap-1">
{DASH_PATTERNS.map((pattern) => (
<button
key={pattern.value}
onClick={() => handleStyleChange({ strokeDash: pattern.value })}
className={`flex items-center justify-between px-2 py-1.5 text-xs rounded-md transition-colors ${
(layer.shapeStyle.strokeDash ?? 'solid') === pattern.value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
<span>{pattern.label}</span>
<span className="font-mono text-[10px] opacity-70">{pattern.preview}</span>
</button>
))}
</div>
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
{layer.shapeType === 'rectangle' && (
<div className="p-3 space-y-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Corner Radius</label>
<button
onClick={() => {
const currentRadius = layer.shapeStyle.cornerRadius ?? 0;
const defaultCorners = { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 };
handleStyleChange({
individualCorners: !layer.shapeStyle.individualCorners,
corners: layer.shapeStyle.individualCorners
? (layer.shapeStyle.corners ?? defaultCorners)
: {
topLeft: currentRadius,
topRight: currentRadius,
bottomRight: currentRadius,
bottomLeft: currentRadius,
},
});
}}
className={`flex items-center gap-1 px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.shapeStyle.individualCorners
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title={layer.shapeStyle.individualCorners ? 'Link corners' : 'Unlink corners'}
>
{layer.shapeStyle.individualCorners ? <Unlink size={12} /> : <Link size={12} />}
{layer.shapeStyle.individualCorners ? 'Individual' : 'Uniform'}
</button>
</div>
{!layer.shapeStyle.individualCorners ? (
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">All Corners</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.cornerRadius}px</span>
</div>
<Slider
value={[layer.shapeStyle.cornerRadius]}
onValueChange={([radius]) => handleStyleChange({ cornerRadius: radius })}
min={0}
max={100}
step={1}
/>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Top Left</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.topLeft ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.topLeft ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topLeft: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Top Right</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.topRight ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.topRight ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), topRight: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Bottom Left</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.bottomLeft ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.bottomLeft ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomLeft: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Bottom Right</label>
<span className="text-[10px] text-muted-foreground">{layer.shapeStyle.corners?.bottomRight ?? 0}px</span>
</div>
<Slider
value={[layer.shapeStyle.corners?.bottomRight ?? 0]}
onValueChange={([radius]) =>
handleStyleChange({
corners: { ...(layer.shapeStyle.corners ?? { topLeft: 0, topRight: 0, bottomRight: 0, bottomLeft: 0 }), bottomRight: radius },
})
}
min={0}
max={100}
step={1}
/>
</div>
</div>
)}
</div>
)}
{layer.shapeType === 'polygon' && (
<div className="p-3 space-y-2 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Sides</label>
<span className="text-[10px] text-muted-foreground">{layer.sides ?? 6}</span>
</div>
<Slider
value={[layer.sides ?? 6]}
onValueChange={([sides]) => updateLayer<ShapeLayer>(layer.id, { sides })}
min={3}
max={12}
step={1}
/>
</div>
)}
{layer.shapeType === 'star' && (
<div className="p-3 space-y-3 bg-secondary/50 rounded-lg">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Points</label>
<span className="text-[10px] text-muted-foreground">{layer.sides ?? 5}</span>
</div>
<Slider
value={[layer.sides ?? 5]}
onValueChange={([sides]) => updateLayer<ShapeLayer>(layer.id, { sides })}
min={3}
max={20}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Inner Radius</label>
<span className="text-[10px] text-muted-foreground">{Math.round((layer.innerRadius ?? 0.4) * 100)}%</span>
</div>
<Slider
value={[Math.round((layer.innerRadius ?? 0.4) * 100)]}
onValueChange={([ratio]) => updateLayer<ShapeLayer>(layer.id, { innerRadius: ratio / 100 })}
min={10}
max={90}
step={1}
/>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,100 @@
import { useUIStore } from '../../../stores/ui-store';
import { Blend, RotateCcw } from 'lucide-react';
export function SmudgeToolPanel() {
const { smudgeSettings, setSmudgeSettings } = useUIStore();
const resetSettings = () => {
setSmudgeSettings({
size: 30,
strength: 50,
fingerPainting: false,
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Blend size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Smudge Tool</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Drag to smudge and blend colors together.
</p>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{smudgeSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={smudgeSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Strength</span>
<span className="text-xs font-mono text-muted-foreground">{smudgeSettings.strength}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={smudgeSettings.strength}
onChange={(e) => 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"
/>
</div>
<div className="flex flex-col gap-2 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={smudgeSettings.fingerPainting}
onChange={(e) => setSmudgeSettings({ fingerPainting: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Finger Painting
</label>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={smudgeSettings.sampleAllLayers}
onChange={(e) => setSmudgeSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { useUIStore } from '../../../stores/ui-store';
import { Droplet, RotateCcw } from 'lucide-react';
export function SpongeToolPanel() {
const { spongeSettings, setSpongeSettings } = useUIStore();
const resetSettings = () => {
setSpongeSettings({
size: 30,
flow: 50,
mode: 'desaturate',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Droplet size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Sponge Tool</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Paint to saturate or desaturate color in specific areas.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => setSpongeSettings({ mode: 'desaturate' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
spongeSettings.mode === 'desaturate'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Desaturate
</button>
<button
onClick={() => setSpongeSettings({ mode: 'saturate' })}
className={`px-3 py-2 text-xs rounded transition-colors ${
spongeSettings.mode === 'saturate'
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
Saturate
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{spongeSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={spongeSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Flow</span>
<span className="text-xs font-mono text-muted-foreground">{spongeSettings.flow}%</span>
</div>
<input
type="range"
min={1}
max={100}
value={spongeSettings.flow}
onChange={(e) => 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"
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
import { useUIStore } from '../../../stores/ui-store';
import { Bandage, RotateCcw } from 'lucide-react';
export function SpotHealingToolPanel() {
const { spotHealingSettings, setSpotHealingSettings } = useUIStore();
const resetSettings = () => {
setSpotHealingSettings({
size: 30,
type: 'content-aware',
sampleAllLayers: false,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bandage size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Spot Healing Brush</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Paint over blemishes or imperfections to automatically remove them.
</p>
<div className="space-y-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs text-muted-foreground">Size</span>
<span className="text-xs font-mono text-muted-foreground">{spotHealingSettings.size}px</span>
</div>
<input
type="range"
min={1}
max={500}
value={spotHealingSettings.size}
onChange={(e) => 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"
/>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Type</span>
<div className="flex flex-col gap-1">
{([
{ id: 'content-aware', label: 'Content-Aware' },
{ id: 'proximity-match', label: 'Proximity Match' },
{ id: 'create-texture', label: 'Create Texture' },
] as const).map((option) => (
<button
key={option.id}
onClick={() => setSpotHealingSettings({ type: option.id })}
className={`px-3 py-2 text-xs rounded transition-colors text-left ${
spotHealingSettings.type === option.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={spotHealingSettings.sampleAllLayers}
onChange={(e) => setSpotHealingSettings({ sampleAllLayers: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Sample All Layers
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,595 @@
import { useProjectStore } from '../../../stores/project-store';
import type { TextLayer, TextStyle, TextFillType, Gradient } from '../../../types/project';
import { AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, CaseUpper, CaseLower, CaseSensitive, Strikethrough, Type } from 'lucide-react';
import { FontPicker } from '../../ui/FontPicker';
import { GradientPicker } from '../../ui/GradientPicker';
import { Slider, Switch } from '@openreel/ui';
interface Props {
layer: TextLayer;
}
const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 96, 128];
interface TextPreset {
id: string;
name: string;
style: Partial<TextStyle>;
}
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<TextLayer>(layer.id, { content });
};
const handleStyleChange = (updates: Partial<TextStyle>) => {
updateLayer<TextLayer>(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 (
<div className="space-y-4">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Text
</h4>
<div>
<div className="flex items-center gap-2 mb-2">
<Type size={14} className="text-muted-foreground" />
<label className="text-[10px] text-muted-foreground">Text Presets</label>
</div>
<div className="flex flex-wrap gap-1.5">
{TEXT_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => applyPreset(preset)}
className="px-2.5 py-1.5 text-[10px] rounded-md bg-secondary text-secondary-foreground hover:bg-accent transition-colors"
>
{preset.name}
</button>
))}
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Content</label>
<textarea
value={layer.content}
onChange={(e) => handleContentChange(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-h-[60px] resize-none"
rows={3}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Font</label>
<FontPicker
value={layer.style.fontFamily}
onChange={(fontFamily) => handleStyleChange({ fontFamily })}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Size</label>
<select
value={layer.style.fontSize}
onChange={(e) => handleStyleChange({ fontSize: 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"
>
{FONT_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Weight</label>
<select
value={layer.style.fontWeight}
onChange={(e) => handleStyleChange({ fontWeight: 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"
>
<option value={300}>Light</option>
<option value={400}>Regular</option>
<option value={500}>Medium</option>
<option value={600}>Semibold</option>
<option value={700}>Bold</option>
</select>
</div>
</div>
<div className="flex gap-1">
<button
onClick={toggleBold}
className={`p-2 rounded-md transition-colors ${
layer.style.fontWeight >= 700
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Bold"
>
<Bold size={14} />
</button>
<button
onClick={toggleItalic}
className={`p-2 rounded-md transition-colors ${
layer.style.fontStyle === 'italic'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Italic"
>
<Italic size={14} />
</button>
<button
onClick={toggleUnderline}
className={`p-2 rounded-md transition-colors ${
layer.style.textDecoration === 'underline'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Underline"
>
<Underline size={14} />
</button>
<button
onClick={toggleStrikethrough}
className={`p-2 rounded-md transition-colors ${
layer.style.textDecoration === 'line-through'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Strikethrough"
>
<Strikethrough size={14} />
</button>
<div className="w-px bg-border mx-1" />
<button
onClick={() => handleStyleChange({ textAlign: 'left' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'left'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Left"
>
<AlignLeft size={14} />
</button>
<button
onClick={() => handleStyleChange({ textAlign: 'center' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'center'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Center"
>
<AlignCenter size={14} />
</button>
<button
onClick={() => handleStyleChange({ textAlign: 'right' })}
className={`p-2 rounded-md transition-colors ${
layer.style.textAlign === 'right'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title="Align Right"
>
<AlignRight size={14} />
</button>
<div className="w-px bg-border mx-1" />
<button
onClick={transformToUppercase}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="UPPERCASE"
>
<CaseUpper size={14} />
</button>
<button
onClick={transformToLowercase}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="lowercase"
>
<CaseLower size={14} />
</button>
<button
onClick={transformToCapitalize}
className="p-2 rounded-md transition-colors bg-secondary text-secondary-foreground hover:bg-accent"
title="Capitalize"
>
<CaseSensitive size={14} />
</button>
</div>
<div className="space-y-3 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Fill</label>
<div className="flex gap-1">
<button
onClick={() => handleStyleChange({ fillType: 'solid' as TextFillType })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
(layer.style.fillType ?? 'solid') === 'solid'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Solid
</button>
<button
onClick={() => {
const gradient: Gradient = layer.style.gradient ?? {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: layer.style.color },
{ offset: 1, color: '#8b5cf6' },
],
};
handleStyleChange({ fillType: 'gradient' as TextFillType, gradient });
}}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.fillType === 'gradient'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Gradient
</button>
</div>
</div>
{(layer.style.fillType ?? 'solid') === 'solid' ? (
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.color}
onChange={(e) => handleStyleChange({ color: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.color}
onChange={(e) => handleStyleChange({ 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"
/>
</div>
) : (
<GradientPicker
value={layer.style.gradient}
onChange={(gradient) => handleStyleChange({ gradient })}
/>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Line Height</label>
<input
type="number"
value={layer.style.lineHeight}
onChange={(e) => handleStyleChange({ lineHeight: 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"
step={0.1}
min={0.5}
max={3}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Letter Spacing</label>
<input
type="number"
value={layer.style.letterSpacing}
onChange={(e) => handleStyleChange({ letterSpacing: 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"
step={0.1}
/>
</div>
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Text Stroke</label>
<button
onClick={() => handleStyleChange({ strokeColor: layer.style.strokeColor ? null : '#000000', strokeWidth: layer.style.strokeColor ? 0 : 2 })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.strokeColor
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{layer.style.strokeColor ? 'On' : 'Off'}
</button>
</div>
{layer.style.strokeColor && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.strokeColor}
onChange={(e) => handleStyleChange({ strokeColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.strokeColor}
onChange={(e) => handleStyleChange({ strokeColor: 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"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Width</label>
<span className="text-[10px] text-muted-foreground">{layer.style.strokeWidth ?? 0}px</span>
</div>
<Slider
value={[layer.style.strokeWidth ?? 0]}
onValueChange={([width]) => handleStyleChange({ strokeWidth: width })}
min={0}
max={10}
step={0.5}
/>
</div>
</div>
)}
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Background</label>
<button
onClick={() => handleStyleChange({ backgroundColor: layer.style.backgroundColor ? null : '#000000' })}
className={`px-2 py-1 text-[10px] rounded-md transition-colors ${
layer.style.backgroundColor
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{layer.style.backgroundColor ? 'On' : 'Off'}
</button>
</div>
{layer.style.backgroundColor && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={layer.style.backgroundColor}
onChange={(e) => handleStyleChange({ backgroundColor: e.target.value })}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.backgroundColor}
onChange={(e) => handleStyleChange({ backgroundColor: 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"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Padding</label>
<span className="text-[10px] text-muted-foreground">{layer.style.backgroundPadding ?? 8}px</span>
</div>
<Slider
value={[layer.style.backgroundPadding ?? 8]}
onValueChange={([padding]) => handleStyleChange({ backgroundPadding: padding })}
min={0}
max={32}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-[10px] text-muted-foreground">Radius</label>
<span className="text-[10px] text-muted-foreground">{layer.style.backgroundRadius ?? 4}px</span>
</div>
<Slider
value={[layer.style.backgroundRadius ?? 4]}
onValueChange={([radius]) => handleStyleChange({ backgroundRadius: radius })}
min={0}
max={32}
step={1}
/>
</div>
</div>
</div>
)}
</div>
<div className="space-y-2 p-3 bg-secondary/50 rounded-lg">
<div className="flex items-center justify-between">
<label className="text-xs font-medium">Text Shadow</label>
<Switch
checked={layer.style.textShadow?.enabled ?? false}
onCheckedChange={(enabled) =>
handleStyleChange({
textShadow: {
...(layer.style.textShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }),
enabled,
},
})
}
/>
</div>
{layer.style.textShadow?.enabled && (
<div className="space-y-3">
<div>
<label className="block text-[10px] text-muted-foreground mb-1.5">Color</label>
<div className="flex items-center gap-2">
<input
type="color"
value={(layer.style.textShadow?.color ?? 'rgba(0, 0, 0, 0.5)').startsWith('rgba') ? '#000000' : layer.style.textShadow?.color ?? '#000000'}
onChange={(e) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), color: e.target.value },
})
}
className="w-8 h-8 rounded border border-input cursor-pointer"
/>
<input
type="text"
value={layer.style.textShadow?.color ?? 'rgba(0, 0, 0, 0.5)'}
onChange={(e) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), 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"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Blur</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.blur ?? 4}px</span>
</div>
<Slider
value={[layer.style.textShadow?.blur ?? 4]}
onValueChange={([blur]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), blur },
})
}
min={0}
max={50}
step={1}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset X</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.offsetX ?? 0}px</span>
</div>
<Slider
value={[layer.style.textShadow?.offsetX ?? 0]}
onValueChange={([offsetX]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), offsetX },
})
}
min={-30}
max={30}
step={1}
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Offset Y</label>
<span className="text-[10px] text-muted-foreground">{layer.style.textShadow?.offsetY ?? 2}px</span>
</div>
<Slider
value={[layer.style.textShadow?.offsetY ?? 2]}
onValueChange={([offsetY]) =>
handleStyleChange({
textShadow: { ...(layer.style.textShadow ?? { enabled: true, color: 'rgba(0, 0, 0, 0.5)', blur: 4, offsetX: 0, offsetY: 2 }), offsetY },
})
}
min={-30}
max={30}
step={1}
/>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,108 @@
import { useState } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
import { DEFAULT_THRESHOLD } from '../../../types/adjustments';
import { Binary, RotateCcw } from 'lucide-react';
interface Props {
layer: Layer;
}
export function ThresholdSection({ layer }: Props) {
const { updateLayer } = useProjectStore();
const [isExpanded, setIsExpanded] = useState(false);
const threshold = layer.threshold;
const handleLevelChange = (level: number) => {
updateLayer(layer.id, {
threshold: { ...threshold, level },
});
};
const handleEnabledChange = (enabled: boolean) => {
updateLayer(layer.id, {
threshold: { ...threshold, enabled },
});
};
const resetThreshold = () => {
updateLayer(layer.id, {
threshold: { ...DEFAULT_THRESHOLD },
});
};
const percentage = (threshold.level / 255) * 100;
return (
<div className="border-b border-border">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-secondary/50 transition-colors"
>
<div className="flex items-center gap-2">
<Binary size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Threshold</span>
{threshold.enabled && (
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
)}
</div>
<input
type="checkbox"
checked={threshold.enabled}
onChange={(e) => {
e.stopPropagation();
handleEnabledChange(e.target.checked);
}}
className="w-3.5 h-3.5 rounded border-border"
/>
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Threshold Level</span>
<div className="flex items-center gap-2">
<span className="text-[10px] font-mono text-muted-foreground">{threshold.level}</span>
<button
onClick={resetThreshold}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={12} />
</button>
</div>
</div>
<input
type="range"
value={threshold.level}
min={0}
max={255}
onChange={(e) => handleLevelChange(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, #000000 0%, #000000 ${percentage}%, #ffffff ${percentage}%, #ffffff 100%)`
}}
/>
<div className="flex justify-between text-[9px] text-muted-foreground">
<span>0 (Black)</span>
<span>255 (White)</span>
</div>
<p className="text-[9px] text-muted-foreground">
Pixels below the threshold become black, above become white.
</p>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,187 @@
import { FlipHorizontal2, FlipVertical2, RotateCw, RotateCcw } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer } from '../../../types/project';
interface Props {
layer: Layer;
}
export function TransformSection({ layer }: Props) {
const { updateLayer, updateLayerTransform } = useProjectStore();
const { x, y, width, height, rotation, skewX, skewY, opacity } = layer.transform;
const flipH = layer.flipHorizontal ?? false;
const flipV = layer.flipVertical ?? false;
const handleChange = (key: string, value: number) => {
updateLayerTransform(layer.id, { [key]: value });
};
const handleFlipHorizontal = () => {
updateLayer(layer.id, { flipHorizontal: !flipH });
};
const handleFlipVertical = () => {
updateLayer(layer.id, { flipVertical: !flipV });
};
const handleRotate = (degrees: number) => {
updateLayerTransform(layer.id, {
rotation: (rotation + degrees) % 360,
});
};
return (
<div className="space-y-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Transform
</h4>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">X</label>
<input
type="number"
value={Math.round(x)}
onChange={(e) => handleChange('x', 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"
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Y</label>
<input
type="number"
value={Math.round(y)}
onChange={(e) => handleChange('y', 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Width</label>
<input
type="number"
value={Math.round(width)}
onChange={(e) => handleChange('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}
/>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Height</label>
<input
type="number"
value={Math.round(height)}
onChange={(e) => handleChange('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}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Rotation</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(rotation)}
onChange={(e) => handleChange('rotation', Number(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"
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Opacity</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(opacity * 100)}
onChange={(e) => handleChange('opacity', Number(e.target.value) / 100)}
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"
min={0}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Skew X</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(skewX ?? 0)}
onChange={(e) => handleChange('skewX', Number(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"
min={-89}
max={89}
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
<div>
<label className="block text-[10px] text-muted-foreground mb-1">Skew Y</label>
<div className="flex items-center gap-1">
<input
type="number"
value={Math.round(skewY ?? 0)}
onChange={(e) => handleChange('skewY', Number(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"
min={-89}
max={89}
/>
<span className="text-xs text-muted-foreground">°</span>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-1.5">
<button
onClick={handleFlipHorizontal}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-all ${
flipH
? 'bg-primary/20 border-primary text-primary'
: 'bg-secondary/50 border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title="Flip Horizontal"
>
<FlipHorizontal2 size={14} />
</button>
<button
onClick={handleFlipVertical}
className={`flex items-center justify-center gap-1 p-2 rounded-md border transition-all ${
flipV
? 'bg-primary/20 border-primary text-primary'
: 'bg-secondary/50 border-border text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title="Flip Vertical"
>
<FlipVertical2 size={14} />
</button>
<button
onClick={() => handleRotate(-90)}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
title="Rotate 90° Counter-clockwise"
>
<RotateCcw size={14} />
</button>
<button
onClick={() => handleRotate(90)}
className="flex items-center justify-center gap-1 p-2 rounded-md bg-secondary/50 border border-border text-muted-foreground hover:text-foreground hover:bg-secondary transition-all"
title="Rotate 90° Clockwise"
>
<RotateCw size={14} />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useUIStore } from '../../../stores/ui-store';
import { Move, RotateCcw, Scale, RotateCw, ArrowUpDown, Maximize2, Grid3x3 } from 'lucide-react';
const transformModes = [
{ id: 'free', label: 'Free', icon: Move },
{ id: 'scale', label: 'Scale', icon: Scale },
{ id: 'rotate', label: 'Rotate', icon: RotateCw },
{ id: 'skew', label: 'Skew', icon: ArrowUpDown },
{ id: 'distort', label: 'Distort', icon: Maximize2 },
{ id: 'perspective', label: 'Perspective', icon: Maximize2 },
{ id: 'warp', label: 'Warp', icon: Grid3x3 },
] as const;
export function TransformToolPanel() {
const { transformSettings, setTransformSettings } = useUIStore();
const resetSettings = () => {
setTransformSettings({
mode: 'free',
maintainAspectRatio: false,
interpolation: 'bicubic',
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Move size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium">Transform</h3>
</div>
<button
onClick={resetSettings}
className="p-1 text-muted-foreground hover:text-foreground rounded hover:bg-secondary transition-colors"
title="Reset"
>
<RotateCcw size={14} />
</button>
</div>
<p className="text-xs text-muted-foreground">
Select a layer and use handles to transform. Press Enter to apply, Escape to cancel.
</p>
<div className="space-y-3">
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Mode</span>
<div className="grid grid-cols-4 gap-1">
{transformModes.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.id}
onClick={() => setTransformSettings({ mode: mode.id })}
className={`flex flex-col items-center gap-1 p-2 rounded transition-colors ${
transformSettings.mode === mode.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
title={mode.label}
>
<Icon size={14} />
<span className="text-[9px]">{mode.label}</span>
</button>
);
})}
</div>
</div>
<div>
<span className="text-xs text-muted-foreground mb-1.5 block">Interpolation</span>
<div className="grid grid-cols-3 gap-1">
{(['nearest', 'bilinear', 'bicubic'] as const).map((interp) => (
<button
key={interp}
onClick={() => setTransformSettings({ interpolation: interp })}
className={`px-2 py-1.5 text-xs rounded transition-colors capitalize ${
transformSettings.interpolation === interp
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:text-foreground hover:bg-secondary'
}`}
>
{interp}
</button>
))}
</div>
</div>
<div className="pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={transformSettings.maintainAspectRatio}
onChange={(e) => setTransformSettings({ maintainAspectRatio: e.target.checked })}
className="w-3.5 h-3.5 rounded border-border"
/>
Maintain Aspect Ratio (Shift)
</label>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,440 @@
import { useState, useRef, useEffect } from 'react';
import { Eye, EyeOff, Lock, Unlock, Trash2, Copy, ChevronUp, ChevronDown, ArrowUp, ArrowDown, ArrowUpToLine, ArrowDownToLine, Clipboard, ClipboardCopy, Scissors, Paintbrush, Search, X, Image, Type, Hexagon, Folder, FolderPlus, FolderOpen } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import type { Layer, LayerType } from '../../../types/project';
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuCheckboxItem,
Slider,
} from '@openreel/ui';
type FilterType = 'all' | LayerType;
const LAYER_TYPE_ICONS: Record<LayerType, React.ReactNode> = {
image: <Image size={12} />,
text: <Type size={12} />,
shape: <Hexagon size={12} />,
group: <Folder size={12} />,
'smart-object': <FolderOpen size={12} />,
};
export function LayerPanel() {
const {
project,
selectedLayerIds,
selectedArtboardId,
copiedStyle,
selectLayer,
selectLayers,
updateLayer,
updateLayerTransform,
removeLayer,
duplicateLayer,
moveLayerUp,
moveLayerDown,
moveLayerToTop,
moveLayerToBottom,
copyLayers,
cutLayers,
pasteLayers,
copyLayerStyle,
pasteLayerStyle,
groupLayers,
ungroupLayers,
} = useProjectStore();
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<FilterType>('all');
const [editingLayerId, setEditingLayerId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const editInputRef = useRef<HTMLInputElement>(null);
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
const allLayers = artboard?.layerIds.map((id) => project?.layers[id]).filter(Boolean) as Layer[] ?? [];
const layers = allLayers.filter((layer) => {
const matchesSearch = searchQuery === '' || layer.name.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = filterType === 'all' || layer.type === filterType;
return matchesSearch && matchesType;
});
const handleSelectAllByType = (type: LayerType) => {
const layerIds = allLayers.filter((l) => l.type === type).map((l) => l.id);
if (layerIds.length > 0) {
selectLayers(layerIds);
}
};
const handleStartRename = (layer: Layer) => {
setEditingLayerId(layer.id);
setEditingName(layer.name);
};
const handleFinishRename = () => {
if (editingLayerId && editingName.trim()) {
updateLayer(editingLayerId, { name: editingName.trim() });
}
setEditingLayerId(null);
setEditingName('');
};
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleFinishRename();
} else if (e.key === 'Escape') {
setEditingLayerId(null);
setEditingName('');
}
};
useEffect(() => {
if (editingLayerId && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingLayerId]);
const handleToggleVisibility = (layer: Layer, e: React.MouseEvent) => {
e.stopPropagation();
updateLayer(layer.id, { visible: !layer.visible });
};
const handleToggleLock = (layer: Layer, e: React.MouseEvent) => {
e.stopPropagation();
updateLayer(layer.id, { locked: !layer.locked });
};
const handleDelete = (layerId: string, e: React.MouseEvent) => {
e.stopPropagation();
removeLayer(layerId);
};
const handleDuplicate = (layerId: string, e: React.MouseEvent) => {
e.stopPropagation();
duplicateLayer(layerId);
};
const getLayerIcon = (type: Layer['type']) => {
switch (type) {
case 'image':
return '🖼️';
case 'text':
return 'T';
case 'shape':
return '◆';
case 'group':
return '📁';
default:
return '•';
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<h3 className="text-xs font-medium text-foreground">Layers</h3>
<span className="text-[10px] text-muted-foreground">
{layers.length}/{allLayers.length}
</span>
</div>
<div className="px-2 py-2 border-b border-border space-y-2">
<div className="relative">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
placeholder="Search layers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-7 pr-7 py-1.5 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<div className="flex gap-1">
<button
onClick={() => setFilterType('all')}
className={`flex-1 px-1.5 py-1 text-[10px] rounded transition-colors ${
filterType === 'all' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
All
</button>
{(['image', 'text', 'shape', 'group', 'smart-object'] as LayerType[]).map((type) => (
<button
key={type}
onClick={() => setFilterType(filterType === type ? 'all' : type)}
onDoubleClick={() => handleSelectAllByType(type)}
aria-label={`Filter ${type} layers`}
className={`p-1.5 rounded transition-colors ${
filterType === type ? 'bg-primary text-primary-foreground' : 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
title={`Filter ${type}s (double-click to select all)`}
>
{LAYER_TYPE_ICONS[type]}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto">
{layers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center p-4">
<p className="text-xs text-muted-foreground">No layers yet</p>
<p className="text-[10px] text-muted-foreground mt-1">
Add text, shapes, or images
</p>
</div>
) : (
<div className="py-1">
{layers.map((layer) => {
const isSelected = selectedLayerIds.includes(layer.id);
return (
<ContextMenu key={layer.id}>
<ContextMenuTrigger asChild>
<div
onClick={() => selectLayer(layer.id)}
className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors ${
isSelected
? 'bg-primary/20 border-l-2 border-primary'
: 'hover:bg-accent border-l-2 border-transparent'
}`}
>
<span
className={`w-5 h-5 flex items-center justify-center text-xs rounded ${
layer.type === 'text' ? 'font-bold' : ''
}`}
>
{getLayerIcon(layer.type)}
</span>
{editingLayerId === layer.id ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onBlur={handleFinishRename}
onKeyDown={handleRenameKeyDown}
onClick={(e) => e.stopPropagation()}
className="flex-1 text-xs bg-background border border-primary rounded px-1 py-0.5 focus:outline-none"
/>
) : (
<span
onDoubleClick={(e) => {
e.stopPropagation();
handleStartRename(layer);
}}
className={`flex-1 text-xs truncate ${
layer.visible ? 'text-foreground' : 'text-muted-foreground'
} ${layer.locked ? 'italic' : ''}`}
title="Double-click to rename"
>
{layer.name}
</span>
)}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleToggleVisibility(layer, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={layer.visible ? 'Hide' : 'Show'}
>
{layer.visible ? <Eye size={12} /> : <EyeOff size={12} />}
</button>
<button
onClick={(e) => handleToggleLock(layer, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title={layer.locked ? 'Unlock' : 'Lock'}
>
{layer.locked ? <Lock size={12} /> : <Unlock size={12} />}
</button>
<button
onClick={(e) => handleDuplicate(layer.id, e)}
className="p-1 rounded hover:bg-background text-muted-foreground hover:text-foreground"
title="Duplicate"
>
<Copy size={12} />
</button>
<button
onClick={(e) => handleDelete(layer.id, e)}
className="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 size={12} />
</button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => { selectLayer(layer.id); copyLayers(); }}>
<ClipboardCopy size={14} className="mr-2" />
Copy
<ContextMenuShortcut>C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => { selectLayer(layer.id); cutLayers(); }}>
<Scissors size={14} className="mr-2" />
Cut
<ContextMenuShortcut>X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={pasteLayers}>
<Clipboard size={14} className="mr-2" />
Paste
<ContextMenuShortcut>V</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => duplicateLayer(layer.id)}>
<Copy size={14} className="mr-2" />
Duplicate
<ContextMenuShortcut>D</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
{selectedLayerIds.length > 1 && (
<ContextMenuItem onClick={() => groupLayers(selectedLayerIds)}>
<FolderPlus size={14} className="mr-2" />
Group Selection
<ContextMenuShortcut>G</ContextMenuShortcut>
</ContextMenuItem>
)}
{layer.type === 'group' && (
<ContextMenuItem onClick={() => ungroupLayers(layer.id)}>
<FolderOpen size={14} className="mr-2" />
Ungroup
<ContextMenuShortcut>G</ContextMenuShortcut>
</ContextMenuItem>
)}
{(selectedLayerIds.length > 1 || layer.type === 'group') && <ContextMenuSeparator />}
<ContextMenuItem onClick={() => { selectLayer(layer.id); copyLayerStyle(); }}>
<Paintbrush size={14} className="mr-2" />
Copy Style
</ContextMenuItem>
<ContextMenuItem onClick={pasteLayerStyle} disabled={!copiedStyle}>
<Paintbrush size={14} className="mr-2" />
Paste Style
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => moveLayerToTop(layer.id)}>
<ArrowUpToLine size={14} className="mr-2" />
Bring to Front
<ContextMenuShortcut>]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerUp(layer.id)}>
<ArrowUp size={14} className="mr-2" />
Bring Forward
<ContextMenuShortcut>]</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerDown(layer.id)}>
<ArrowDown size={14} className="mr-2" />
Send Backward
<ContextMenuShortcut>[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={() => moveLayerToBottom(layer.id)}>
<ArrowDownToLine size={14} className="mr-2" />
Send to Back
<ContextMenuShortcut>[</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={layer.visible}
onCheckedChange={() => updateLayer(layer.id, { visible: !layer.visible })}
>
{layer.visible ? <Eye size={14} className="mr-2" /> : <EyeOff size={14} className="mr-2" />}
Visible
</ContextMenuCheckboxItem>
<ContextMenuCheckboxItem
checked={layer.locked}
onCheckedChange={() => updateLayer(layer.id, { locked: !layer.locked })}
>
{layer.locked ? <Lock size={14} className="mr-2" /> : <Unlock size={14} className="mr-2" />}
Locked
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => removeLayer(layer.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
<ContextMenuShortcut></ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
)}
</div>
{selectedLayerIds.length > 1 && (
<div className="p-2 border-t border-border">
<button
onClick={() => groupLayers(selectedLayerIds)}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors"
>
<FolderPlus size={14} />
Group {selectedLayerIds.length} Layers
</button>
</div>
)}
{selectedLayerIds.length === 1 && (
<div className="p-2 border-t border-border space-y-2">
{project?.layers[selectedLayerIds[0]]?.type === 'group' && (
<button
onClick={() => ungroupLayers(selectedLayerIds[0])}
className="w-full flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md bg-secondary text-secondary-foreground text-xs font-medium hover:bg-secondary/80 transition-colors mb-2"
>
<FolderOpen size={14} />
Ungroup
</button>
)}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground w-12">Opacity</span>
<Slider
value={[project?.layers[selectedLayerIds[0]]?.transform.opacity ?? 1]}
onValueChange={([opacity]) => updateLayerTransform(selectedLayerIds[0], { opacity })}
min={0}
max={1}
step={0.01}
className="flex-1"
/>
<span className="text-[10px] text-muted-foreground w-8 text-right">
{Math.round((project?.layers[selectedLayerIds[0]]?.transform.opacity ?? 1) * 100)}%
</span>
</div>
<div className="flex items-center justify-center gap-1">
<button
onClick={() => moveLayerUp(selectedLayerIds[0])}
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
title="Move up (Cmd+])"
>
<ChevronUp size={14} />
</button>
<button
onClick={() => moveLayerDown(selectedLayerIds[0])}
className="p-1.5 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
title="Move down (Cmd+[)"
>
<ChevronDown size={14} />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,199 @@
import { useState, useRef } from 'react';
import { Plus, Trash2, Copy, MoreHorizontal, ChevronUp, ChevronDown } from 'lucide-react';
import { useProjectStore } from '../../../stores/project-store';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@openreel/ui';
export function PagesBar() {
const {
project,
selectedArtboardId,
selectArtboard,
addArtboard,
removeArtboard,
updateArtboard,
} = useProjectStore();
const [isExpanded, setIsExpanded] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
if (!project) return null;
const artboards = project.artboards;
const handleAddPage = () => {
const currentArtboard = artboards.find((a) => a.id === selectedArtboardId);
const size = currentArtboard?.size ?? { width: 1080, height: 1080 };
const newId = addArtboard(`Page ${artboards.length + 1}`, size);
selectArtboard(newId);
};
const handleDuplicatePage = (artboardId: string) => {
const artboard = artboards.find((a) => a.id === artboardId);
if (!artboard) return;
const newId = addArtboard(`${artboard.name} copy`, artboard.size);
selectArtboard(newId);
};
const handleDeletePage = (artboardId: string) => {
if (artboards.length <= 1) return;
removeArtboard(artboardId);
};
const handleRename = (artboardId: string, newName: string) => {
if (newName.trim()) {
updateArtboard(artboardId, { name: newName.trim() });
}
setEditingId(null);
};
const handleStartRename = (artboardId: string) => {
setEditingId(artboardId);
setTimeout(() => inputRef.current?.select(), 0);
};
return (
<div className="bg-card border-t border-border">
<div className="flex items-center justify-between px-3 py-1.5">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Pages</span>
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full">
{artboards.length}
</span>
</button>
<button
onClick={handleAddPage}
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors"
>
<Plus size={14} />
<span>Add Page</span>
</button>
</div>
{isExpanded && (
<div className="px-3 pb-3 overflow-x-auto">
<div className="flex gap-2">
{artboards.map((artboard) => {
const isSelected = artboard.id === selectedArtboardId;
const aspectRatio = artboard.size.width / artboard.size.height;
const thumbHeight = 64;
const thumbWidth = Math.min(thumbHeight * aspectRatio, 100);
return (
<div
key={artboard.id}
className={`group relative flex-shrink-0 rounded-lg border-2 transition-all cursor-pointer ${
isSelected
? 'border-primary ring-2 ring-primary/20'
: 'border-border hover:border-muted-foreground'
}`}
onClick={() => selectArtboard(artboard.id)}
>
<div
className="bg-muted rounded-md flex items-center justify-center overflow-hidden"
style={{ width: thumbWidth, height: thumbHeight }}
>
<div
className="rounded-sm"
style={{
width: thumbWidth - 8,
height: thumbHeight - 8,
backgroundColor:
artboard.background.type === 'color'
? artboard.background.color
: artboard.background.type === 'transparent'
? 'transparent'
: '#ffffff',
backgroundImage:
artboard.background.type === 'transparent'
? 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)'
: undefined,
backgroundSize: '8px 8px',
backgroundPosition: '0 0, 0 4px, 4px -4px, -4px 0px',
}}
/>
</div>
<div className="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="w-5 h-5 flex items-center justify-center bg-background border border-border rounded shadow-sm hover:bg-accent transition-colors"
>
<MoreHorizontal size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => handleStartRename(artboard.id)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDuplicatePage(artboard.id)}>
<Copy size={14} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeletePage(artboard.id)}
disabled={artboards.length <= 1}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="absolute -bottom-5 left-0 right-0 text-center">
{editingId === artboard.id ? (
<input
ref={inputRef}
type="text"
defaultValue={artboard.name}
onBlur={(e) => handleRename(artboard.id, e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRename(artboard.id, e.currentTarget.value);
} else if (e.key === 'Escape') {
setEditingId(null);
}
}}
onClick={(e) => e.stopPropagation()}
className="w-full text-[10px] text-center bg-transparent border-none focus:outline-none focus:ring-1 focus:ring-primary rounded px-1"
autoFocus
/>
) : (
<span
className={`text-[10px] truncate max-w-full inline-block ${
isSelected ? 'text-foreground font-medium' : 'text-muted-foreground'
}`}
onDoubleClick={(e) => {
e.stopPropagation();
handleStartRename(artboard.id);
}}
>
{artboard.name}
</span>
)}
</div>
</div>
);
})}
<button
onClick={handleAddPage}
className="flex-shrink-0 w-16 h-16 flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border hover:border-muted-foreground hover:bg-accent/50 transition-all"
>
<Plus size={20} className="text-muted-foreground" />
</button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,290 @@
import { useState } from 'react';
import { Ruler, Plus, Trash2, X, ArrowRight, ArrowDown } from 'lucide-react';
import { useCanvasStore, type Guide } from '../../../stores/canvas-store';
import { useProjectStore } from '../../../stores/project-store';
export function GuidePanel() {
const { guides, addGuide, removeGuide, updateGuide, clearGuides } = useCanvasStore();
const { project, selectedArtboardId } = useProjectStore();
const [editingGuideId, setEditingGuideId] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState('');
const [showAddForm, setShowAddForm] = useState(false);
const [newGuideType, setNewGuideType] = useState<'horizontal' | 'vertical'>('horizontal');
const [newGuidePosition, setNewGuidePosition] = useState('');
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
const horizontalGuides = guides.filter((g) => g.type === 'horizontal');
const verticalGuides = guides.filter((g) => g.type === 'vertical');
const handleAddGuide = () => {
const position = parseFloat(newGuidePosition);
if (!isNaN(position)) {
addGuide(newGuideType, position);
setNewGuidePosition('');
setShowAddForm(false);
}
};
const handleStartEdit = (guide: Guide) => {
setEditingGuideId(guide.id);
setEditingValue(guide.position.toString());
};
const handleFinishEdit = () => {
if (editingGuideId) {
const position = parseFloat(editingValue);
if (!isNaN(position)) {
updateGuide(editingGuideId, position);
}
}
setEditingGuideId(null);
setEditingValue('');
};
const handleAddCenterGuides = () => {
if (artboard) {
addGuide('horizontal', artboard.size.height / 2);
addGuide('vertical', artboard.size.width / 2);
}
};
const handleAddThirdsGuides = () => {
if (artboard) {
addGuide('horizontal', artboard.size.height / 3);
addGuide('horizontal', (artboard.size.height * 2) / 3);
addGuide('vertical', artboard.size.width / 3);
addGuide('vertical', (artboard.size.width * 2) / 3);
}
};
const handleAddEdgeGuides = () => {
if (artboard) {
const margin = Math.min(artboard.size.width, artboard.size.height) * 0.1;
addGuide('horizontal', margin);
addGuide('horizontal', artboard.size.height - margin);
addGuide('vertical', margin);
addGuide('vertical', artboard.size.width - margin);
}
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="flex items-center gap-2">
<Ruler size={14} className="text-muted-foreground" />
<h3 className="text-xs font-medium text-foreground">Guides</h3>
</div>
<span className="text-[10px] text-muted-foreground">{guides.length}</span>
</div>
<div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-3">
<div className="flex gap-1">
<button
onClick={() => setShowAddForm(true)}
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-primary text-primary-foreground text-[10px] font-medium hover:bg-primary/90 transition-colors"
>
<Plus size={12} />
Add Guide
</button>
{guides.length > 0 && (
<button
onClick={clearGuides}
className="p-1.5 rounded-md bg-destructive/10 text-destructive hover:bg-destructive/20 transition-colors"
title="Clear all guides"
>
<Trash2 size={14} />
</button>
)}
</div>
{showAddForm && (
<div className="p-2 bg-secondary/50 rounded-lg space-y-2">
<div className="flex gap-1">
<button
onClick={() => setNewGuideType('horizontal')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded text-[10px] transition-colors ${
newGuideType === 'horizontal'
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'
}`}
>
<ArrowRight size={10} />
Horizontal
</button>
<button
onClick={() => setNewGuideType('vertical')}
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded text-[10px] transition-colors ${
newGuideType === 'vertical'
? 'bg-primary text-primary-foreground'
: 'bg-background text-muted-foreground hover:text-foreground'
}`}
>
<ArrowDown size={10} />
Vertical
</button>
</div>
<div className="flex gap-1">
<input
type="number"
value={newGuidePosition}
onChange={(e) => setNewGuidePosition(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleAddGuide();
if (e.key === 'Escape') setShowAddForm(false);
}}
placeholder={newGuideType === 'horizontal' ? 'Y position...' : 'X position...'}
className="flex-1 px-2 py-1 text-[10px] bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
<button
onClick={handleAddGuide}
className="px-2 py-1 rounded bg-primary text-primary-foreground text-[10px] hover:bg-primary/90 transition-colors"
>
Add
</button>
<button
onClick={() => setShowAddForm(false)}
className="p-1 rounded bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
>
<X size={12} />
</button>
</div>
</div>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground font-medium">Quick Presets</span>
<div className="grid grid-cols-3 gap-1">
<button
onClick={handleAddCenterGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Center
</button>
<button
onClick={handleAddThirdsGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Thirds
</button>
<button
onClick={handleAddEdgeGuides}
className="px-2 py-1.5 rounded bg-secondary/50 text-[9px] text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
Margins
</button>
</div>
</div>
{horizontalGuides.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<ArrowRight size={10} className="text-blue-400" />
<span className="text-[10px] text-muted-foreground">
Horizontal ({horizontalGuides.length})
</span>
</div>
<div className="space-y-0.5">
{horizontalGuides.map((guide) => (
<div
key={guide.id}
className="flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 group"
>
<span className="text-[10px] text-blue-400 w-4">Y</span>
{editingGuideId === guide.id ? (
<input
type="number"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleFinishEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEdit();
if (e.key === 'Escape') setEditingGuideId(null);
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-primary rounded focus:outline-none"
autoFocus
/>
) : (
<button
onClick={() => handleStartEdit(guide)}
className="flex-1 text-left text-[10px] text-foreground hover:text-primary transition-colors"
>
{Math.round(guide.position)}px
</button>
)}
<button
onClick={() => removeGuide(guide.id)}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-all"
>
<X size={10} />
</button>
</div>
))}
</div>
</div>
)}
{verticalGuides.length > 0 && (
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<ArrowDown size={10} className="text-green-400" />
<span className="text-[10px] text-muted-foreground">
Vertical ({verticalGuides.length})
</span>
</div>
<div className="space-y-0.5">
{verticalGuides.map((guide) => (
<div
key={guide.id}
className="flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 group"
>
<span className="text-[10px] text-green-400 w-4">X</span>
{editingGuideId === guide.id ? (
<input
type="number"
value={editingValue}
onChange={(e) => setEditingValue(e.target.value)}
onBlur={handleFinishEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEdit();
if (e.key === 'Escape') setEditingGuideId(null);
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-primary rounded focus:outline-none"
autoFocus
/>
) : (
<button
onClick={() => handleStartEdit(guide)}
className="flex-1 text-left text-[10px] text-foreground hover:text-primary transition-colors"
>
{Math.round(guide.position)}px
</button>
)}
<button
onClick={() => removeGuide(guide.id)}
className="p-0.5 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-all"
>
<X size={10} />
</button>
</div>
))}
</div>
</div>
)}
{guides.length === 0 && !showAddForm && (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Ruler size={24} className="text-muted-foreground/40 mb-2" />
<p className="text-[10px] text-muted-foreground">No guides yet</p>
<p className="text-[9px] text-muted-foreground/60 mt-0.5">
Click "Add Guide" or use presets
</p>
</div>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,275 @@
import { useState } from 'react';
import {
History,
Undo2,
Redo2,
Trash2,
Clock,
Camera,
Bookmark,
ChevronDown,
ChevronRight,
Edit2,
Check,
X,
} from 'lucide-react';
import { useHistoryStore } from '../../../stores/history-store';
import { useProjectStore } from '../../../stores/project-store';
import { formatDistanceToNow } from '../../../utils/time';
export function HistoryPanel() {
const entries = useHistoryStore((s) => s.getEntries());
const currentIndex = useHistoryStore((s) => s.getCurrentIndex());
const snapshots = useHistoryStore((s) => s.getSnapshots());
const clear = useHistoryStore((s) => s.clear);
const canUndo = useHistoryStore((s) => s.canUndo);
const canRedo = useHistoryStore((s) => s.canRedo);
const createSnapshot = useHistoryStore((s) => s.createSnapshot);
const restoreSnapshot = useHistoryStore((s) => s.restoreSnapshot);
const deleteSnapshot = useHistoryStore((s) => s.deleteSnapshot);
const renameSnapshot = useHistoryStore((s) => s.renameSnapshot);
const goToEntry = useHistoryStore((s) => s.goToEntry);
const { project, loadProject, undo, redo } = useProjectStore();
const [showSnapshots, setShowSnapshots] = useState(true);
const [editingSnapshotId, setEditingSnapshotId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const handleUndo = () => {
undo();
};
const handleRedo = () => {
redo();
};
const handleJumpToState = (index: number) => {
if (index === currentIndex) return;
const state = goToEntry(index);
if (state) {
loadProject(state);
}
};
const handleCreateSnapshot = () => {
if (!project) return;
const name = `Snapshot ${snapshots.length + 1}`;
createSnapshot(name, project);
};
const handleRestoreSnapshot = (id: string) => {
const state = restoreSnapshot(id);
if (state) {
loadProject(state);
}
};
const handleStartRename = (id: string, currentName: string) => {
setEditingSnapshotId(id);
setEditingName(currentName);
};
const handleSaveRename = () => {
if (editingSnapshotId && editingName.trim()) {
renameSnapshot(editingSnapshotId, editingName.trim());
}
setEditingSnapshotId(null);
setEditingName('');
};
const handleCancelRename = () => {
setEditingSnapshotId(null);
setEditingName('');
};
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-3 border-b border-border">
<div className="flex items-center gap-2">
<History size={16} className="text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">History</h3>
<span className="text-[10px] bg-muted px-1.5 py-0.5 rounded-full text-muted-foreground">
{entries.length}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleUndo}
disabled={!canUndo()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Undo (Ctrl+Z)"
>
<Undo2 size={14} />
</button>
<button
onClick={handleRedo}
disabled={!canRedo()}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 size={14} />
</button>
<button
onClick={() => clear(project ?? undefined)}
disabled={entries.length === 0}
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
title="Clear history"
>
<Trash2 size={14} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="border-b border-border">
<button
onClick={() => setShowSnapshots(!showSnapshots)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-accent/50 transition-colors"
>
{showSnapshots ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<Bookmark size={12} className="text-muted-foreground" />
<span className="text-[11px] font-medium">Snapshots</span>
<span className="text-[10px] text-muted-foreground ml-auto">{snapshots.length}</span>
</button>
{showSnapshots && (
<div className="px-2 pb-2">
<button
onClick={handleCreateSnapshot}
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 mb-2 text-[10px] rounded bg-secondary hover:bg-secondary/80 transition-colors"
>
<Camera size={10} />
New Snapshot
</button>
{snapshots.length === 0 ? (
<p className="text-[10px] text-muted-foreground text-center py-2">
No snapshots yet
</p>
) : (
<div className="space-y-1">
{snapshots.map((snapshot) => (
<div
key={snapshot.id}
className="group flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent/50 transition-colors"
>
{editingSnapshotId === snapshot.id ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveRename();
if (e.key === 'Escape') handleCancelRename();
}}
className="flex-1 px-1 py-0.5 text-[10px] bg-background border border-border rounded"
autoFocus
/>
<button
onClick={handleSaveRename}
className="p-0.5 text-green-500 hover:text-green-400"
>
<Check size={10} />
</button>
<button
onClick={handleCancelRename}
className="p-0.5 text-muted-foreground hover:text-foreground"
>
<X size={10} />
</button>
</div>
) : (
<>
<button
onClick={() => handleRestoreSnapshot(snapshot.id)}
className="flex-1 text-left"
>
<p className="text-[10px] font-medium truncate">{snapshot.name}</p>
<p className="text-[9px] text-muted-foreground">
{formatDistanceToNow(snapshot.timestamp)}
</p>
</button>
<div className="hidden group-hover:flex items-center gap-0.5">
<button
onClick={() => handleStartRename(snapshot.id, snapshot.name)}
className="p-0.5 text-muted-foreground hover:text-foreground"
>
<Edit2 size={10} />
</button>
<button
onClick={() => deleteSnapshot(snapshot.id)}
className="p-0.5 text-muted-foreground hover:text-destructive"
>
<Trash2 size={10} />
</button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
{entries.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Clock size={32} className="text-muted-foreground/50 mb-3" />
<p className="text-xs text-muted-foreground">No history yet</p>
<p className="text-[10px] text-muted-foreground/70 mt-1">
Your actions will appear here
</p>
</div>
) : (
<div className="py-1">
{[...entries].reverse().map((entry, reverseIndex) => {
const index = entries.length - 1 - reverseIndex;
const isCurrent = index === currentIndex;
const isFuture = index > currentIndex;
return (
<button
key={entry.id}
onClick={() => handleJumpToState(index)}
className={`w-full flex items-start gap-2 px-3 py-2 text-left transition-colors ${
isCurrent
? 'bg-primary/10 border-l-2 border-primary'
: isFuture
? 'opacity-50 hover:opacity-75 hover:bg-accent/50'
: 'hover:bg-accent'
}`}
>
<div
className={`mt-0.5 w-2 h-2 rounded-full flex-shrink-0 ${
isCurrent
? 'bg-primary'
: isFuture
? 'bg-muted-foreground/30'
: 'bg-muted-foreground/50'
}`}
/>
<div className="min-w-0 flex-1">
<p
className={`text-xs truncate ${
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
}`}
>
{entry.description}
</p>
<p className="text-[9px] text-muted-foreground/70">
{formatDistanceToNow(entry.timestamp)}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,367 @@
import { useState, useRef, useEffect } from 'react';
import {
MousePointer2,
Hand,
Type,
Square,
PenTool,
Pipette,
ZoomIn,
Undo2,
Redo2,
Download,
Save,
PanelLeftClose,
PanelRightClose,
Home,
ChevronDown,
SquareDashed,
Circle,
Lasso,
Wand2,
Crop,
Eraser,
Paintbrush,
PaintBucket,
Stamp,
Bandage,
Droplet,
Droplets,
Blend,
Move,
Maximize2,
Grid3x3,
Waves,
Sun,
Moon,
Spline,
SquareStack,
} from 'lucide-react';
import { useUIStore, Tool } from '../../../stores/ui-store';
import { useProjectStore } from '../../../stores/project-store';
import { ZoomControl } from './ZoomControl';
interface ToolItem {
id: Tool;
icon: React.ElementType;
label: string;
shortcut?: string;
}
interface ToolGroup {
id: string;
label: string;
tools: ToolItem[];
}
const toolGroups: ToolGroup[] = [
{
id: 'navigation',
label: 'Navigation',
tools: [
{ id: 'select', icon: MousePointer2, label: 'Move', shortcut: 'V' },
{ id: 'hand', icon: Hand, label: 'Hand', shortcut: 'H' },
{ id: 'zoom', icon: ZoomIn, label: 'Zoom', shortcut: 'Z' },
],
},
{
id: 'selection',
label: 'Selection',
tools: [
{ id: 'marquee-rect', icon: SquareDashed, label: 'Rectangular Marquee', shortcut: 'M' },
{ id: 'marquee-ellipse', icon: Circle, label: 'Elliptical Marquee', shortcut: 'M' },
{ id: 'lasso', icon: Lasso, label: 'Lasso', shortcut: 'L' },
{ id: 'lasso-polygon', icon: Spline, label: 'Polygon Lasso', shortcut: 'L' },
{ id: 'magic-wand', icon: Wand2, label: 'Magic Wand', shortcut: 'W' },
],
},
{
id: 'crop-transform',
label: 'Crop & Transform',
tools: [
{ id: 'crop', icon: Crop, label: 'Crop', shortcut: 'C' },
{ id: 'free-transform', icon: Move, label: 'Free Transform', shortcut: 'T' },
{ id: 'perspective', icon: Maximize2, label: 'Perspective', shortcut: '' },
{ id: 'warp', icon: Grid3x3, label: 'Warp', shortcut: '' },
{ id: 'liquify', icon: Waves, label: 'Liquify', shortcut: '' },
],
},
{
id: 'paint',
label: 'Paint',
tools: [
{ id: 'brush', icon: Paintbrush, label: 'Brush', shortcut: 'B' },
{ id: 'eraser', icon: Eraser, label: 'Eraser', shortcut: 'E' },
{ id: 'paint-bucket', icon: PaintBucket, label: 'Paint Bucket', shortcut: 'G' },
{ id: 'gradient', icon: SquareStack, label: 'Gradient', shortcut: 'G' },
],
},
{
id: 'retouch',
label: 'Retouch',
tools: [
{ id: 'clone-stamp', icon: Stamp, label: 'Clone Stamp', shortcut: 'S' },
{ id: 'healing-brush', icon: Bandage, label: 'Healing Brush', shortcut: 'J' },
{ id: 'spot-healing', icon: Bandage, label: 'Spot Healing', shortcut: 'J' },
],
},
{
id: 'adjust',
label: 'Adjust',
tools: [
{ id: 'dodge', icon: Sun, label: 'Dodge', shortcut: 'O' },
{ id: 'burn', icon: Moon, label: 'Burn', shortcut: 'O' },
{ id: 'sponge', icon: Droplet, label: 'Sponge', shortcut: 'O' },
{ id: 'blur', icon: Droplets, label: 'Blur', shortcut: 'R' },
{ id: 'sharpen', icon: Droplets, label: 'Sharpen', shortcut: 'R' },
{ id: 'smudge', icon: Blend, label: 'Smudge', shortcut: 'R' },
],
},
{
id: 'draw',
label: 'Draw',
tools: [
{ id: 'pen', icon: PenTool, label: 'Pen', shortcut: 'P' },
{ id: 'shape', icon: Square, label: 'Shape', shortcut: 'U' },
{ id: 'text', icon: Type, label: 'Text', shortcut: 'T' },
],
},
{
id: 'sample',
label: 'Sample',
tools: [{ id: 'eyedropper', icon: Pipette, label: 'Eyedropper', shortcut: 'I' }],
},
];
function ToolGroupButton({
group,
activeTool,
onSelectTool,
}: {
group: ToolGroup;
activeTool: Tool;
onSelectTool: (tool: Tool) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(null);
const isGroupActive = group.tools.some((t) => t.id === activeTool);
const currentTool = group.tools.find((t) => t.id === activeTool) || group.tools[selectedIndex];
const Icon = currentTool.icon;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
const handleToolSelect = (tool: ToolItem, index: number) => {
setSelectedIndex(index);
onSelectTool(tool.id);
setIsOpen(false);
};
if (group.tools.length === 1) {
return (
<button
onClick={() => onSelectTool(group.tools[0].id)}
className={`p-2 rounded-md transition-all ${
isGroupActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={`${currentTool.label}${currentTool.shortcut ? ` (${currentTool.shortcut})` : ''}`}
>
<Icon size={18} />
</button>
);
}
return (
<div ref={menuRef} className="relative">
<button
onClick={() => onSelectTool(currentTool.id)}
onContextMenu={(e) => {
e.preventDefault();
setIsOpen(!isOpen);
}}
className={`relative p-2 rounded-md transition-all group ${
isGroupActive
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
}`}
title={`${currentTool.label}${currentTool.shortcut ? ` (${currentTool.shortcut})` : ''} - Right-click for more`}
>
<Icon size={18} />
<span
className={`absolute bottom-0.5 right-0.5 w-0 h-0 border-l-[4px] border-l-transparent border-b-[4px] ${
isGroupActive ? 'border-b-primary-foreground/60' : 'border-b-muted-foreground/40'
}`}
/>
</button>
{isOpen && (
<div className="absolute left-full top-0 ml-1 z-50 py-1 bg-popover border border-border rounded-lg shadow-lg min-w-[180px]">
{group.tools.map((tool, index) => {
const ToolIcon = tool.icon;
return (
<button
key={tool.id}
onClick={() => handleToolSelect(tool, index)}
className={`w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors ${
activeTool === tool.id
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
<ToolIcon size={16} />
<span className="flex-1 text-left">{tool.label}</span>
{tool.shortcut && (
<span className="text-xs text-muted-foreground/60">{tool.shortcut}</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}
export function Toolbar() {
const {
activeTool,
setActiveTool,
togglePanelCollapsed,
toggleInspectorCollapsed,
setCurrentView,
openExportDialog,
} = useUIStore();
const { project, setProjectName, undo, redo, canUndo, canRedo } = useProjectStore();
const handleUndo = () => {
undo();
};
const handleRedo = () => {
redo();
};
const handleSaveProject = () => {
if (!project) return;
const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${project.name.replace(/[^a-zA-Z0-9]/g, '_')}.orimg`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="h-12 bg-card border-b border-border flex items-center px-3 gap-2">
<button
onClick={() => setCurrentView('welcome')}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Home"
>
<Home size={18} />
</button>
<div className="w-px h-6 bg-border mx-1" />
<button
onClick={togglePanelCollapsed}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Toggle left panel"
>
<PanelLeftClose size={18} />
</button>
<div className="w-px h-6 bg-border mx-1" />
<div className="flex items-center">
<input
type="text"
value={project?.name ?? 'Untitled'}
onChange={(e) => setProjectName(e.target.value)}
className="w-48 px-3 py-1.5 text-sm font-medium bg-transparent border border-transparent hover:border-border focus:border-primary focus:outline-none rounded-lg text-foreground"
/>
<ChevronDown size={14} className="text-muted-foreground -ml-6" />
</div>
<div className="flex-1" />
<div className="flex items-center gap-0.5 bg-secondary/50 rounded-lg p-1">
{toolGroups.map((group, idx) => (
<div key={group.id} className="flex items-center">
<ToolGroupButton group={group} activeTool={activeTool} onSelectTool={setActiveTool} />
{idx < toolGroups.length - 1 && idx % 2 === 1 && (
<div className="w-px h-5 bg-border/50 mx-0.5" />
)}
</div>
))}
</div>
<div className="flex-1" />
<div className="flex items-center gap-1">
<button
onClick={handleUndo}
disabled={!canUndo()}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Undo (Ctrl+Z)"
>
<Undo2 size={18} />
</button>
<button
onClick={handleRedo}
disabled={!canRedo()}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
title="Redo (Ctrl+Shift+Z)"
>
<Redo2 size={18} />
</button>
</div>
<div className="w-px h-6 bg-border mx-1" />
<ZoomControl />
<div className="w-px h-6 bg-border mx-1" />
<button
onClick={handleSaveProject}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Save Project (Ctrl+S)"
>
<Save size={18} />
</button>
<button
onClick={openExportDialog}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 active:scale-[0.98] transition-all"
>
<Download size={16} />
Export
</button>
<button
onClick={toggleInspectorCollapsed}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Toggle right panel"
>
<PanelRightClose size={18} />
</button>
</div>
);
}

View file

@ -0,0 +1,147 @@
import { ChevronDown, Minus, Plus, Maximize2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Slider,
} from '@openreel/ui';
import { useUIStore } from '../../../stores/ui-store';
import { useProjectStore } from '../../../stores/project-store';
const ZOOM_PRESETS = [
{ label: '25%', value: 0.25 },
{ label: '50%', value: 0.5 },
{ label: '75%', value: 0.75 },
{ label: '100%', value: 1 },
{ label: '150%', value: 1.5 },
{ label: '200%', value: 2 },
{ label: '400%', value: 4 },
];
export function ZoomControl() {
const { zoom, setZoom, zoomIn, zoomOut, resetView } = useUIStore();
const { project } = useProjectStore();
const handleZoomToFit = () => {
const activeId = project?.activeArtboardId;
const artboard = activeId ? project?.artboards.find((a) => a.id === activeId) : null;
if (!artboard) {
resetView();
return;
}
const viewportWidth = window.innerWidth - 600;
const viewportHeight = window.innerHeight - 200;
if (artboard.size.width <= 0 || artboard.size.height <= 0) {
resetView();
return;
}
const fitZoom = Math.min(
viewportWidth / artboard.size.width,
viewportHeight / artboard.size.height,
1
);
setZoom(Math.max(0.1, Math.min(fitZoom * 0.9, 1)));
};
const handleZoomToFill = () => {
const activeId = project?.activeArtboardId;
const artboard = activeId ? project?.artboards.find((a) => a.id === activeId) : null;
if (!artboard) {
resetView();
return;
}
const viewportWidth = window.innerWidth - 600;
const viewportHeight = window.innerHeight - 200;
if (artboard.size.width <= 0 || artboard.size.height <= 0) {
resetView();
return;
}
const fillZoom = Math.max(
viewportWidth / artboard.size.width,
viewportHeight / artboard.size.height
);
setZoom(Math.min(fillZoom * 0.9, 4));
};
const handleSliderChange = (values: number[]) => {
const logValue = values[0];
const zoom = Math.pow(10, logValue);
setZoom(zoom);
};
const logZoom = Math.log10(zoom);
return (
<div className="flex items-center gap-1">
<button
onClick={zoomOut}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Zoom out (-)"
>
<Minus size={14} />
</button>
<div className="w-20 px-1">
<Slider
value={[logZoom]}
onValueChange={handleSliderChange}
min={Math.log10(0.1)}
max={Math.log10(8)}
step={0.01}
className="[&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<button
onClick={zoomIn}
className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Zoom in (+)"
>
<Plus size={14} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors min-w-[52px] justify-center">
{Math.round(zoom * 100)}%
<ChevronDown size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[120px]">
{ZOOM_PRESETS.map((preset) => (
<DropdownMenuItem
key={preset.value}
onClick={() => setZoom(preset.value)}
className={zoom === preset.value ? 'bg-accent' : ''}
>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleZoomToFit}>
<Maximize2 size={14} className="mr-2" />
Fit to Canvas
</DropdownMenuItem>
<DropdownMenuItem onClick={handleZoomToFill}>
<Maximize2 size={14} className="mr-2" />
Fill View
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={resetView}>
Reset (100%)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View file

@ -0,0 +1,169 @@
import { useState } from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui';
import { ChevronDown, Palette } from 'lucide-react';
export interface ColorPalette {
id: string;
name: string;
colors: string[];
}
export const PRESET_PALETTES: ColorPalette[] = [
{
id: 'modern-minimal',
name: 'Modern Minimal',
colors: ['#ffffff', '#f5f5f5', '#d4d4d4', '#737373', '#262626', '#000000'],
},
{
id: 'ocean-breeze',
name: 'Ocean Breeze',
colors: ['#0ea5e9', '#38bdf8', '#7dd3fc', '#bae6fd', '#e0f2fe', '#f0f9ff'],
},
{
id: 'sunset-glow',
name: 'Sunset Glow',
colors: ['#dc2626', '#f97316', '#facc15', '#fde047', '#fef08a', '#fefce8'],
},
{
id: 'forest-green',
name: 'Forest Green',
colors: ['#14532d', '#166534', '#22c55e', '#4ade80', '#86efac', '#dcfce7'],
},
{
id: 'royal-purple',
name: 'Royal Purple',
colors: ['#581c87', '#7c3aed', '#a78bfa', '#c4b5fd', '#ddd6fe', '#f5f3ff'],
},
{
id: 'warm-earth',
name: 'Warm Earth',
colors: ['#78350f', '#a16207', '#ca8a04', '#facc15', '#fde68a', '#fefce8'],
},
{
id: 'cool-slate',
name: 'Cool Slate',
colors: ['#1e293b', '#334155', '#475569', '#64748b', '#94a3b8', '#cbd5e1'],
},
{
id: 'rose-garden',
name: 'Rose Garden',
colors: ['#881337', '#be123c', '#f43f5e', '#fb7185', '#fda4af', '#ffe4e6'],
},
{
id: 'neon-nights',
name: 'Neon Nights',
colors: ['#0d0d0d', '#7c3aed', '#ec4899', '#22d3ee', '#a3e635', '#fbbf24'],
},
{
id: 'pastel-dreams',
name: 'Pastel Dreams',
colors: ['#fce7f3', '#dbeafe', '#dcfce7', '#fef3c7', '#f3e8ff', '#ffe4e6'],
},
{
id: 'monochrome-blue',
name: 'Monochrome Blue',
colors: ['#172554', '#1e3a8a', '#2563eb', '#60a5fa', '#93c5fd', '#dbeafe'],
},
{
id: 'autumn-harvest',
name: 'Autumn Harvest',
colors: ['#7c2d12', '#c2410c', '#ea580c', '#fb923c', '#fdba74', '#fed7aa'],
},
];
interface ColorPalettesProps {
onColorSelect: (color: string) => void;
selectedColor?: string;
}
export function ColorPalettes({ onColorSelect, selectedColor }: ColorPalettesProps) {
const [isOpen, setIsOpen] = useState(false);
const [expandedPalette, setExpandedPalette] = useState<string | null>(null);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Color Palettes</span>
</div>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-2 space-y-2 bg-background/50 rounded-b-lg border border-t-0 border-border max-h-[300px] overflow-y-auto">
{PRESET_PALETTES.map((palette) => (
<div key={palette.id} className="space-y-1">
<button
onClick={() => setExpandedPalette(expandedPalette === palette.id ? null : palette.id)}
className="w-full flex items-center justify-between px-2 py-1 rounded hover:bg-secondary/50 transition-colors"
>
<span className="text-[10px] text-muted-foreground">{palette.name}</span>
<div className="flex gap-0.5">
{palette.colors.slice(0, 6).map((color, i) => (
<div
key={i}
className="w-3 h-3 rounded-sm border border-border/50"
style={{ backgroundColor: color }}
/>
))}
</div>
</button>
{expandedPalette === palette.id && (
<div className="grid grid-cols-6 gap-1 px-2 pb-1">
{palette.colors.map((color, i) => (
<button
key={i}
onClick={() => onColorSelect(color)}
className={`w-full aspect-square rounded border transition-all hover:scale-110 ${
selectedColor === color
? 'border-primary ring-2 ring-primary/30'
: 'border-border/50 hover:border-border'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
)}
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
);
}
interface QuickColorSwatchesProps {
onColorSelect: (color: string) => void;
selectedColor?: string;
}
export function QuickColorSwatches({ onColorSelect, selectedColor }: QuickColorSwatchesProps) {
const quickColors = [
'#000000', '#ffffff', '#ef4444', '#f97316', '#eab308', '#22c55e',
'#14b8a6', '#3b82f6', '#8b5cf6', '#ec4899', '#6b7280', '#1f2937',
];
return (
<div className="space-y-1.5">
<label className="text-[10px] text-muted-foreground">Quick Colors</label>
<div className="grid grid-cols-6 gap-1">
{quickColors.map((color) => (
<button
key={color}
onClick={() => onColorSelect(color)}
className={`w-full aspect-square rounded border transition-all hover:scale-110 ${
selectedColor === color
? 'border-primary ring-2 ring-primary/30'
: 'border-border/50 hover:border-border'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,430 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { Pipette, Check } from 'lucide-react';
interface ColorPickerProps {
color: string;
onChange: (color: string) => void;
showAlpha?: boolean;
recentColors?: string[];
onRecentColorAdd?: (color: string) => void;
}
interface HSV {
h: number;
s: number;
v: number;
}
interface RGB {
r: number;
g: number;
b: number;
}
function hexToRgb(hex: string): RGB {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 };
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
}
function rgbToHsv(r: number, g: number, b: number): HSV {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
const s = max === 0 ? 0 : d / max;
const v = max;
if (max !== min) {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return { h: h * 360, s: s * 100, v: v * 100 };
}
function hsvToRgb(h: number, s: number, v: number): RGB {
h /= 360;
s /= 100;
v /= 100;
let r = 0, g = 0, b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
};
}
export function ColorPicker({
color,
onChange,
showAlpha = false,
recentColors = [],
onRecentColorAdd,
}: ColorPickerProps) {
const [isPickingColor, setIsPickingColor] = useState(false);
const svPanelRef = useRef<HTMLDivElement>(null);
const hueSliderRef = useRef<HTMLDivElement>(null);
const alphaSliderRef = useRef<HTMLDivElement>(null);
const rgb = hexToRgb(color.slice(0, 7));
const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b);
const alpha = color.length === 9 ? parseInt(color.slice(7, 9), 16) / 255 : 1;
const [localHsv, setLocalHsv] = useState<HSV>(hsv);
const [localAlpha, setLocalAlpha] = useState(alpha);
const [hexInput, setHexInput] = useState(color.slice(0, 7));
const [inputMode, setInputMode] = useState<'hex' | 'rgb'>('hex');
useEffect(() => {
const newRgb = hexToRgb(color.slice(0, 7));
const newHsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b);
setLocalHsv(newHsv);
setHexInput(color.slice(0, 7));
if (color.length === 9) {
setLocalAlpha(parseInt(color.slice(7, 9), 16) / 255);
}
}, [color]);
const updateColor = useCallback(
(h: number, s: number, v: number, a: number = localAlpha) => {
const newRgb = hsvToRgb(h, s, v);
let newColor = rgbToHex(newRgb.r, newRgb.g, newRgb.b);
if (showAlpha && a < 1) {
newColor += Math.round(a * 255).toString(16).padStart(2, '0');
}
onChange(newColor);
setLocalHsv({ h, s, v });
setLocalAlpha(a);
},
[onChange, showAlpha, localAlpha]
);
const handleSVPanelMouseDown = useCallback(
(e: React.MouseEvent) => {
const panel = svPanelRef.current;
if (!panel) return;
const updateFromEvent = (event: MouseEvent | React.MouseEvent) => {
const rect = panel.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height));
updateColor(localHsv.h, x * 100, (1 - y) * 100);
};
updateFromEvent(e);
const handleMouseMove = (event: MouseEvent) => updateFromEvent(event);
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[localHsv.h, updateColor]
);
const handleHueSliderMouseDown = useCallback(
(e: React.MouseEvent) => {
const slider = hueSliderRef.current;
if (!slider) return;
const updateFromEvent = (event: MouseEvent | React.MouseEvent) => {
const rect = slider.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
updateColor(x * 360, localHsv.s, localHsv.v);
};
updateFromEvent(e);
const handleMouseMove = (event: MouseEvent) => updateFromEvent(event);
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[localHsv.s, localHsv.v, updateColor]
);
const handleAlphaSliderMouseDown = useCallback(
(e: React.MouseEvent) => {
const slider = alphaSliderRef.current;
if (!slider) return;
const updateFromEvent = (event: MouseEvent | React.MouseEvent) => {
const rect = slider.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
updateColor(localHsv.h, localHsv.s, localHsv.v, x);
};
updateFromEvent(e);
const handleMouseMove = (event: MouseEvent) => updateFromEvent(event);
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[localHsv.h, localHsv.s, localHsv.v, updateColor]
);
const handleHexInputChange = (value: string) => {
setHexInput(value);
if (/^#[0-9a-fA-F]{6}$/.test(value)) {
const newRgb = hexToRgb(value);
const newHsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b);
updateColor(newHsv.h, newHsv.s, newHsv.v);
onRecentColorAdd?.(value);
}
};
const handleRgbInputChange = (channel: 'r' | 'g' | 'b', value: number) => {
const newRgb = { ...rgb, [channel]: Math.max(0, Math.min(255, value)) };
const newHsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b);
updateColor(newHsv.h, newHsv.s, newHsv.v);
};
const handleEyedropper = async () => {
if (!('EyeDropper' in window)) {
return;
}
setIsPickingColor(true);
try {
const eyeDropper = new (window as unknown as { EyeDropper: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper();
const result = await eyeDropper.open();
const newRgb = hexToRgb(result.sRGBHex);
const newHsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b);
updateColor(newHsv.h, newHsv.s, newHsv.v);
onRecentColorAdd?.(result.sRGBHex);
} catch {
// User cancelled
} finally {
setIsPickingColor(false);
}
};
const hueColor = hsvToRgb(localHsv.h, 100, 100);
const currentRgb = hsvToRgb(localHsv.h, localHsv.s, localHsv.v);
return (
<div className="space-y-3">
<div
ref={svPanelRef}
className="relative w-full h-32 rounded-lg cursor-crosshair"
style={{
backgroundColor: rgbToHex(hueColor.r, hueColor.g, hueColor.b),
}}
onMouseDown={handleSVPanelMouseDown}
>
<div
className="absolute inset-0 rounded-lg"
style={{
background: 'linear-gradient(to right, white, transparent)',
}}
/>
<div
className="absolute inset-0 rounded-lg"
style={{
background: 'linear-gradient(to top, black, transparent)',
}}
/>
<div
className="absolute w-4 h-4 border-2 border-white rounded-full shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${localHsv.s}%`,
top: `${100 - localHsv.v}%`,
backgroundColor: rgbToHex(currentRgb.r, currentRgb.g, currentRgb.b),
}}
/>
</div>
<div
ref={hueSliderRef}
className="relative h-3 rounded-full cursor-pointer"
style={{
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
}}
onMouseDown={handleHueSliderMouseDown}
>
<div
className="absolute w-4 h-4 border-2 border-white rounded-full shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${(localHsv.h / 360) * 100}%`,
top: '50%',
backgroundColor: rgbToHex(hueColor.r, hueColor.g, hueColor.b),
}}
/>
</div>
{showAlpha && (
<div
ref={alphaSliderRef}
className="relative h-3 rounded-full cursor-pointer"
style={{
background: `linear-gradient(to right, transparent, ${rgbToHex(currentRgb.r, currentRgb.g, currentRgb.b)}), url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Crect fill='%23ccc' width='4' height='4'/%3E%3Crect fill='%23fff' x='4' width='4' height='4'/%3E%3Crect fill='%23fff' y='4' width='4' height='4'/%3E%3Crect fill='%23ccc' x='4' y='4' width='4' height='4'/%3E%3C/svg%3E")`,
}}
onMouseDown={handleAlphaSliderMouseDown}
>
<div
className="absolute w-4 h-4 border-2 border-white rounded-full shadow-md -translate-x-1/2 -translate-y-1/2 pointer-events-none"
style={{
left: `${localAlpha * 100}%`,
top: '50%',
backgroundColor: rgbToHex(currentRgb.r, currentRgb.g, currentRgb.b),
}}
/>
</div>
)}
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-md border border-border shadow-inner"
style={{
backgroundColor: rgbToHex(currentRgb.r, currentRgb.g, currentRgb.b),
opacity: localAlpha,
}}
/>
{'EyeDropper' in window && (
<button
onClick={handleEyedropper}
disabled={isPickingColor}
className={`p-2 rounded-md transition-colors ${
isPickingColor
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
<Pipette size={14} />
</button>
)}
<div className="flex-1">
<div className="flex gap-1 mb-1">
<button
onClick={() => setInputMode('hex')}
className={`px-2 py-0.5 text-[9px] font-medium rounded transition-colors ${
inputMode === 'hex' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
HEX
</button>
<button
onClick={() => setInputMode('rgb')}
className={`px-2 py-0.5 text-[9px] font-medium rounded transition-colors ${
inputMode === 'rgb' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
RGB
</button>
</div>
{inputMode === 'hex' ? (
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInputChange(e.target.value)}
className="w-full px-2 py-1 text-[11px] font-mono bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
placeholder="#000000"
/>
) : (
<div className="flex gap-1">
<input
type="number"
value={rgb.r}
onChange={(e) => handleRgbInputChange('r', Number(e.target.value))}
min={0}
max={255}
className="w-full px-1 py-1 text-[11px] font-mono bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary text-center"
/>
<input
type="number"
value={rgb.g}
onChange={(e) => handleRgbInputChange('g', Number(e.target.value))}
min={0}
max={255}
className="w-full px-1 py-1 text-[11px] font-mono bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary text-center"
/>
<input
type="number"
value={rgb.b}
onChange={(e) => handleRgbInputChange('b', Number(e.target.value))}
min={0}
max={255}
className="w-full px-1 py-1 text-[11px] font-mono bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary text-center"
/>
</div>
)}
</div>
</div>
{recentColors.length > 0 && (
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground uppercase tracking-wide">Recent</label>
<div className="flex gap-1 flex-wrap">
{recentColors.slice(0, 10).map((c, i) => (
<button
key={`${c}-${i}`}
onClick={() => {
const newRgb = hexToRgb(c);
const newHsv = rgbToHsv(newRgb.r, newRgb.g, newRgb.b);
updateColor(newHsv.h, newHsv.s, newHsv.v);
}}
className="w-6 h-6 rounded-md border border-border hover:ring-2 hover:ring-primary transition-all relative group"
style={{ backgroundColor: c }}
>
{c === color.slice(0, 7) && (
<Check size={12} className="absolute inset-0 m-auto text-white drop-shadow-md" />
)}
</button>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,106 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
interface DialogProps {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
description?: string;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl';
}
const MAX_WIDTH_CLASSES = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
export function Dialog({ open, onClose, children, title, description, maxWidth = 'md' }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
const handleClickOutside = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [open, onClose]);
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
ref={dialogRef}
className={`relative w-full ${MAX_WIDTH_CLASSES[maxWidth]} mx-4 bg-background border border-border rounded-xl shadow-2xl animate-in fade-in zoom-in-95 duration-200`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? 'dialog-title' : undefined}
>
{(title || description) && (
<div className="flex items-start justify-between p-5 border-b border-border">
<div>
{title && (
<h2 id="dialog-title" className="text-lg font-semibold text-foreground">
{title}
</h2>
)}
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
>
<X size={18} />
</button>
</div>
)}
<div className="p-5">{children}</div>
</div>
</div>
);
}
interface DialogFooterProps {
children: ReactNode;
}
export function DialogFooter({ children }: DialogFooterProps) {
return (
<div className="flex items-center justify-end gap-3 pt-4 mt-4 border-t border-border">
{children}
</div>
);
}

View file

@ -0,0 +1,170 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { Search, Check, ChevronDown, Loader2 } from 'lucide-react';
import {
getPopularFonts,
filterFonts,
loadGoogleFont,
isFontLoaded,
FONT_CATEGORIES,
type GoogleFont,
} from '../../services/fonts-service';
interface FontPickerProps {
value: string;
onChange: (fontFamily: string) => void;
}
export function FontPicker({ value, onChange }: FontPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [loadingFont, setLoadingFont] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const fonts = useMemo(() => getPopularFonts(), []);
const filteredFonts = useMemo(() => filterFonts(fonts, category, search), [fonts, category, search]);
useEffect(() => {
loadGoogleFont(value);
}, [value]);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (isOpen && listRef.current) {
filteredFonts.slice(0, 10).forEach((font) => {
if (!isFontLoaded(font.family)) {
loadGoogleFont(font.family, ['400']);
}
});
}
}, [isOpen, filteredFonts]);
const handleSelect = async (font: GoogleFont) => {
setLoadingFont(font.family);
try {
await loadGoogleFont(font.family, font.variants.slice(0, 4));
onChange(font.family);
setIsOpen(false);
setSearch('');
} finally {
setLoadingFont(null);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const container = e.currentTarget;
const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (scrollBottom < 200) {
const startIndex = Math.floor(container.scrollTop / 40);
const endIndex = Math.min(startIndex + 15, filteredFonts.length);
filteredFonts.slice(startIndex, endIndex).forEach((font) => {
if (!isFontLoaded(font.family)) {
loadGoogleFont(font.family, ['400']);
}
});
}
};
return (
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-2 py-1.5 text-xs bg-background border border-input rounded-md hover:border-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-primary transition-colors"
>
<span style={{ fontFamily: value }} className="truncate">
{value}
</span>
<ChevronDown size={12} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute z-50 top-full left-0 mt-1 w-64 bg-popover border border-border rounded-lg shadow-lg overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
<div className="p-2 border-b border-border">
<div className="relative">
<Search size={14} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search fonts..."
className="w-full pl-7 pr-2 py-1.5 text-xs bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
</div>
</div>
<div className="flex flex-wrap gap-1 p-2 border-b border-border">
{FONT_CATEGORIES.map((cat) => (
<button
key={cat.id}
onClick={() => setCategory(cat.id)}
className={`px-2 py-0.5 text-[10px] rounded-full transition-colors ${
category === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
{cat.name}
</button>
))}
</div>
<div
ref={listRef}
onScroll={handleScroll}
className="max-h-64 overflow-y-auto"
>
{filteredFonts.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
No fonts found
</div>
) : (
filteredFonts.map((font) => (
<button
key={font.family}
onClick={() => handleSelect(font)}
disabled={loadingFont === font.family}
className={`w-full flex items-center justify-between px-3 py-2 text-left hover:bg-accent transition-colors ${
value === font.family ? 'bg-accent/50' : ''
}`}
>
<div className="flex-1 min-w-0">
<span
style={{ fontFamily: font.family }}
className="block text-sm truncate"
>
{font.family}
</span>
<span className="text-[10px] text-muted-foreground capitalize">
{font.category}
</span>
</div>
{loadingFont === font.family ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : value === font.family ? (
<Check size={14} className="text-primary" />
) : null}
</button>
))
)}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,218 @@
import { useState, useCallback, useMemo } from 'react';
import { Plus, Trash2, RotateCw } from 'lucide-react';
import { Slider } from '@openreel/ui';
import type { Gradient } from '../../types/project';
interface GradientPickerProps {
value: Gradient | null;
onChange: (gradient: Gradient) => void;
}
const PRESET_GRADIENTS: Gradient[] = [
{ type: 'linear', angle: 90, stops: [{ offset: 0, color: '#3b82f6' }, { offset: 1, color: '#8b5cf6' }] },
{ type: 'linear', angle: 90, stops: [{ offset: 0, color: '#ec4899' }, { offset: 1, color: '#f97316' }] },
{ type: 'linear', angle: 90, stops: [{ offset: 0, color: '#10b981' }, { offset: 1, color: '#06b6d4' }] },
{ type: 'linear', angle: 180, stops: [{ offset: 0, color: '#fbbf24' }, { offset: 1, color: '#ef4444' }] },
{ type: 'linear', angle: 135, stops: [{ offset: 0, color: '#1e293b' }, { offset: 1, color: '#475569' }] },
{ type: 'radial', angle: 0, stops: [{ offset: 0, color: '#ffffff' }, { offset: 1, color: '#3b82f6' }] },
];
const DEFAULT_GRADIENT: Gradient = {
type: 'linear',
angle: 90,
stops: [
{ offset: 0, color: '#3b82f6' },
{ offset: 1, color: '#8b5cf6' },
],
};
export function GradientPicker({ value, onChange }: GradientPickerProps) {
const gradient = value ?? DEFAULT_GRADIENT;
const [selectedStopIndex, setSelectedStopIndex] = useState(0);
const gradientString = useMemo(() => {
const stopsStr = gradient.stops
.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`)
.join(', ');
return gradient.type === 'linear'
? `linear-gradient(${gradient.angle}deg, ${stopsStr})`
: `radial-gradient(circle, ${stopsStr})`;
}, [gradient]);
const handleTypeChange = useCallback((type: 'linear' | 'radial') => {
onChange({ ...gradient, type });
}, [gradient, onChange]);
const handleAngleChange = useCallback((angle: number) => {
onChange({ ...gradient, angle });
}, [gradient, onChange]);
const handleStopColorChange = useCallback((index: number, color: string) => {
const newStops = [...gradient.stops];
newStops[index] = { ...newStops[index], color };
onChange({ ...gradient, stops: newStops });
}, [gradient, onChange]);
const handleStopOffsetChange = useCallback((index: number, offset: number) => {
const newStops = [...gradient.stops];
newStops[index] = { ...newStops[index], offset };
newStops.sort((a, b) => a.offset - b.offset);
const newIndex = newStops.findIndex((s) => s === newStops[index]);
setSelectedStopIndex(newIndex !== -1 ? newIndex : index);
onChange({ ...gradient, stops: newStops });
}, [gradient, onChange]);
const addStop = useCallback(() => {
if (gradient.stops.length >= 5) return;
const midOffset = gradient.stops.length > 1
? (gradient.stops[0].offset + gradient.stops[gradient.stops.length - 1].offset) / 2
: 0.5;
const newStops = [...gradient.stops, { offset: midOffset, color: '#ffffff' }];
newStops.sort((a, b) => a.offset - b.offset);
onChange({ ...gradient, stops: newStops });
setSelectedStopIndex(newStops.length - 1);
}, [gradient, onChange]);
const removeStop = useCallback((index: number) => {
if (gradient.stops.length <= 2) return;
const newStops = gradient.stops.filter((_, i) => i !== index);
onChange({ ...gradient, stops: newStops });
setSelectedStopIndex(Math.min(selectedStopIndex, newStops.length - 1));
}, [gradient, onChange, selectedStopIndex]);
const selectPreset = useCallback((preset: Gradient) => {
onChange(preset);
setSelectedStopIndex(0);
}, [onChange]);
return (
<div className="space-y-3">
<div
className="h-10 rounded-lg border border-input cursor-pointer"
style={{ background: gradientString }}
/>
<div className="flex gap-1">
{PRESET_GRADIENTS.map((preset, index) => {
const presetStr = preset.type === 'linear'
? `linear-gradient(${preset.angle}deg, ${preset.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`
: `radial-gradient(circle, ${preset.stops.map((s) => `${s.color} ${Math.round(s.offset * 100)}%`).join(', ')})`;
return (
<button
key={index}
onClick={() => selectPreset(preset)}
className="w-8 h-8 rounded border border-input hover:border-primary transition-colors"
style={{ background: presetStr }}
/>
);
})}
</div>
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => handleTypeChange('linear')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
gradient.type === 'linear'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Linear
</button>
<button
onClick={() => handleTypeChange('radial')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${
gradient.type === 'radial'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
Radial
</button>
</div>
{gradient.type === 'linear' && (
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-[10px] text-muted-foreground">Angle</label>
<div className="flex items-center gap-1">
<span className="text-[10px] text-muted-foreground">{gradient.angle}°</span>
<button
onClick={() => handleAngleChange((gradient.angle + 45) % 360)}
className="p-0.5 rounded hover:bg-accent transition-colors"
>
<RotateCw size={12} className="text-muted-foreground" />
</button>
</div>
</div>
<Slider
value={[gradient.angle]}
onValueChange={([angle]) => handleAngleChange(angle)}
min={0}
max={360}
step={1}
/>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Color Stops</label>
<button
onClick={addStop}
disabled={gradient.stops.length >= 5}
className="p-1 rounded hover:bg-accent transition-colors disabled:opacity-50"
>
<Plus size={12} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-2">
{gradient.stops.map((stop, index) => (
<div
key={index}
className={`flex items-center gap-2 p-2 rounded-lg transition-colors ${
selectedStopIndex === index ? 'bg-secondary' : 'hover:bg-secondary/50'
}`}
onClick={() => setSelectedStopIndex(index)}
>
<input
type="color"
value={stop.color}
onChange={(e) => handleStopColorChange(index, e.target.value)}
className="w-6 h-6 rounded border border-input cursor-pointer"
/>
<div className="flex-1">
<Slider
value={[stop.offset * 100]}
onValueChange={([offset]) => handleStopOffsetChange(index, offset / 100)}
min={0}
max={100}
step={1}
/>
</div>
<span className="text-[10px] text-muted-foreground w-8 text-right">
{Math.round(stop.offset * 100)}%
</span>
<button
onClick={(e) => {
e.stopPropagation();
removeStop(index);
}}
disabled={gradient.stops.length <= 2}
className="p-1 rounded hover:bg-destructive/20 transition-colors disabled:opacity-50"
>
<Trash2 size={12} className="text-muted-foreground" />
</button>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,296 @@
import { useState } from 'react';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui';
import { ChevronDown, Plus, Trash2, X, Bookmark, History, FolderPlus, Pencil, Check } from 'lucide-react';
import { useColorStore, type CustomPalette } from '../../stores/color-store';
interface SavedColorsSectionProps {
onColorSelect: (color: string) => void;
selectedColor?: string;
currentColor?: string;
}
export function SavedColorsSection({ onColorSelect, selectedColor, currentColor }: SavedColorsSectionProps) {
const {
recentColors,
savedColors,
customPalettes,
saveColor,
removeSavedColor,
clearSavedColors,
createPalette,
updatePalette,
addColorToPalette,
removeColorFromPalette,
deletePalette,
} = useColorStore();
const [isOpen, setIsOpen] = useState(true);
const [editingPaletteId, setEditingPaletteId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [showNewPaletteInput, setShowNewPaletteInput] = useState(false);
const [newPaletteName, setNewPaletteName] = useState('');
const handleSaveCurrentColor = () => {
if (currentColor) {
saveColor(currentColor);
}
};
const handleCreatePalette = () => {
if (newPaletteName.trim()) {
createPalette(newPaletteName.trim());
setNewPaletteName('');
setShowNewPaletteInput(false);
}
};
const handleStartEditPalette = (palette: CustomPalette) => {
setEditingPaletteId(palette.id);
setEditingName(palette.name);
};
const handleFinishEditPalette = () => {
if (editingPaletteId && editingName.trim()) {
updatePalette(editingPaletteId, { name: editingName.trim() });
}
setEditingPaletteId(null);
setEditingName('');
};
const handleAddCurrentToPalette = (paletteId: string) => {
if (currentColor) {
addColorToPalette(paletteId, currentColor);
}
};
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-2 rounded-lg bg-secondary/50 hover:bg-secondary transition-colors">
<div className="flex items-center gap-2">
<Bookmark size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Saved Colors</span>
</div>
<ChevronDown size={14} className={`text-muted-foreground transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-2 space-y-3 bg-background/50 rounded-b-lg border border-t-0 border-border">
{recentColors.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5">
<History size={12} className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Recent</span>
</div>
<div className="grid grid-cols-6 gap-1">
{recentColors.map((color, i) => (
<button
key={`${color}-${i}`}
onClick={() => onColorSelect(color)}
className={`w-full aspect-square rounded border transition-all hover:scale-110 ${
selectedColor?.toLowerCase() === color.toLowerCase()
? 'border-primary ring-2 ring-primary/30'
: 'border-border/50 hover:border-border'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Bookmark size={12} className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Saved</span>
<span className="text-[9px] text-muted-foreground/60">({savedColors.length})</span>
</div>
<div className="flex items-center gap-1">
{currentColor && (
<button
onClick={handleSaveCurrentColor}
className="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="Save current color"
>
<Plus size={12} />
</button>
)}
{savedColors.length > 0 && (
<button
onClick={clearSavedColors}
className="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Clear all saved colors"
>
<Trash2 size={12} />
</button>
)}
</div>
</div>
{savedColors.length > 0 ? (
<div className="grid grid-cols-6 gap-1">
{savedColors.map((color, i) => (
<div key={`${color}-${i}`} className="relative group">
<button
onClick={() => onColorSelect(color)}
className={`w-full aspect-square rounded border transition-all hover:scale-110 ${
selectedColor?.toLowerCase() === color.toLowerCase()
? 'border-primary ring-2 ring-primary/30'
: 'border-border/50 hover:border-border'
}`}
style={{ backgroundColor: color }}
title={color}
/>
<button
onClick={() => removeSavedColor(color)}
className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
title="Remove color"
>
<X size={8} />
</button>
</div>
))}
</div>
) : (
<p className="text-[10px] text-muted-foreground/60 text-center py-2">
No saved colors yet
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Custom Palettes</span>
<button
onClick={() => setShowNewPaletteInput(true)}
className="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="Create new palette"
>
<FolderPlus size={12} />
</button>
</div>
{showNewPaletteInput && (
<div className="flex gap-1">
<input
type="text"
value={newPaletteName}
onChange={(e) => setNewPaletteName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreatePalette();
if (e.key === 'Escape') setShowNewPaletteInput(false);
}}
placeholder="Palette name..."
className="flex-1 px-2 py-1 text-[10px] bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
<button
onClick={handleCreatePalette}
className="p-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
<Check size={12} />
</button>
<button
onClick={() => setShowNewPaletteInput(false)}
className="p-1 rounded bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
>
<X size={12} />
</button>
</div>
)}
{customPalettes.map((palette) => (
<div key={palette.id} className="space-y-1 p-2 bg-secondary/30 rounded-lg">
<div className="flex items-center justify-between">
{editingPaletteId === palette.id ? (
<div className="flex-1 flex gap-1 mr-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEditPalette();
if (e.key === 'Escape') setEditingPaletteId(null);
}}
className="flex-1 px-1.5 py-0.5 text-[10px] bg-background border border-input rounded focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
<button
onClick={handleFinishEditPalette}
className="p-0.5 rounded bg-primary text-primary-foreground"
>
<Check size={10} />
</button>
</div>
) : (
<span className="text-[10px] font-medium text-foreground">{palette.name}</span>
)}
<div className="flex items-center gap-0.5">
{currentColor && (
<button
onClick={() => handleAddCurrentToPalette(palette.id)}
className="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="Add current color"
>
<Plus size={10} />
</button>
)}
<button
onClick={() => handleStartEditPalette(palette)}
className="p-1 rounded hover:bg-secondary text-muted-foreground hover:text-foreground transition-colors"
title="Rename palette"
>
<Pencil size={10} />
</button>
<button
onClick={() => deletePalette(palette.id)}
className="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Delete palette"
>
<Trash2 size={10} />
</button>
</div>
</div>
{palette.colors.length > 0 ? (
<div className="grid grid-cols-6 gap-1">
{palette.colors.map((color, i) => (
<div key={`${color}-${i}`} className="relative group">
<button
onClick={() => onColorSelect(color)}
className={`w-full aspect-square rounded border transition-all hover:scale-110 ${
selectedColor?.toLowerCase() === color.toLowerCase()
? 'border-primary ring-2 ring-primary/30'
: 'border-border/50 hover:border-border'
}`}
style={{ backgroundColor: color }}
title={color}
/>
<button
onClick={() => removeColorFromPalette(palette.id, color)}
className="absolute -top-1 -right-1 w-3 h-3 rounded-full bg-destructive text-destructive-foreground opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
>
<X size={7} />
</button>
</div>
))}
</div>
) : (
<p className="text-[9px] text-muted-foreground/60 text-center py-1">
Empty palette
</p>
)}
</div>
))}
{customPalettes.length === 0 && !showNewPaletteInput && (
<p className="text-[10px] text-muted-foreground/60 text-center py-2">
No custom palettes yet
</p>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}

View file

@ -0,0 +1,381 @@
import { useState, useEffect } from 'react';
import { Plus, FolderOpen, Image, Layout, FileText, Presentation, Smartphone, Monitor, Star, Trash2, Clock, MoreVertical } from 'lucide-react';
import { useProjectStore } from '../../stores/project-store';
import { useUIStore } from '../../stores/ui-store';
import { CANVAS_PRESETS, Project } from '../../types/project';
import { loadSavedProject, getSavedProjectIds, deleteSavedProject } from '../../hooks/useAutoSave';
type Category = 'all' | 'Social Media' | 'Presentation' | 'Print' | 'Desktop' | 'Mobile' | 'Logo';
interface SavedProjectInfo {
id: string;
name: string;
updatedAt: number;
size: { width: number; height: number };
}
const categories: { id: Category; label: string; icon: React.ElementType }[] = [
{ id: 'all', label: 'All', icon: Layout },
{ id: 'Social Media', label: 'Social Media', icon: Star },
{ id: 'Presentation', label: 'Presentation', icon: Presentation },
{ id: 'Print', label: 'Print', icon: FileText },
{ id: 'Desktop', label: 'Desktop', icon: Monitor },
{ id: 'Mobile', label: 'Mobile', icon: Smartphone },
{ id: 'Logo', label: 'Logo', icon: Image },
];
export function WelcomeScreen() {
const [selectedCategory, setSelectedCategory] = useState<Category>('all');
const [customWidth, setCustomWidth] = useState(1920);
const [customHeight, setCustomHeight] = useState(1080);
const [showCustomSize, setShowCustomSize] = useState(false);
const [recentProjects, setRecentProjects] = useState<SavedProjectInfo[]>([]);
const [projectMenuOpen, setProjectMenuOpen] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const { createProject, loadProject } = useProjectStore();
const { setCurrentView } = useUIStore();
useEffect(() => {
loadRecentProjects();
}, []);
useEffect(() => {
const handleClickOutside = () => setProjectMenuOpen(null);
if (projectMenuOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [projectMenuOpen]);
const loadRecentProjects = () => {
const projectIds = getSavedProjectIds();
const projects: SavedProjectInfo[] = [];
for (const id of projectIds) {
const project = loadSavedProject(id);
if (project) {
projects.push({
id: project.id,
name: project.name,
updatedAt: project.updatedAt,
size: project.artboards?.[0]?.size ?? { width: 0, height: 0 },
});
}
}
projects.sort((a, b) => b.updatedAt - a.updatedAt);
setRecentProjects(projects);
};
const handleOpenProject = (projectId: string) => {
const project = loadSavedProject(projectId);
if (project) {
loadProject(project);
setCurrentView('editor');
}
};
const handleDeleteProject = (projectId: string) => {
deleteSavedProject(projectId);
setRecentProjects((prev) => prev.filter((p) => p.id !== projectId));
setDeleteConfirmId(null);
setProjectMenuOpen(null);
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return minutes <= 1 ? 'Just now' : `${minutes} minutes ago`;
}
return hours === 1 ? '1 hour ago' : `${hours} hours ago`;
}
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString();
};
const filteredPresets = selectedCategory === 'all'
? CANVAS_PRESETS
: CANVAS_PRESETS.filter((p) => p.category === selectedCategory);
const handleCreateProject = (width: number, height: number, name: string) => {
createProject(name, { width, height });
setCurrentView('editor');
};
const handleCreateCustom = () => {
createProject('Untitled Design', { width: customWidth, height: customHeight });
setCurrentView('editor');
};
return (
<div className="h-full w-full bg-background flex flex-col">
<header className="flex items-center justify-between px-8 py-6 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center">
<Image size={20} className="text-primary-foreground" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">OpenReel Image</h1>
<p className="text-sm text-muted-foreground">Professional Graphic Design Editor</p>
</div>
</div>
<button
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.orimg,application/json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
try {
const text = await file.text();
const project = JSON.parse(text) as Project;
if (project && project.id && project.artboards) {
loadProject(project);
setCurrentView('editor');
}
} catch (err) {
console.error('Failed to load project file:', err);
}
}
};
input.click();
}}
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<FolderOpen size={18} />
Open Project
</button>
</header>
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto px-8 py-8">
<section className="mb-10">
<h2 className="text-lg font-semibold text-foreground mb-4">Start a new project</h2>
<div className="flex gap-2 mb-6 flex-wrap">
{categories.map((cat) => {
const Icon = cat.icon;
return (
<button
key={cat.id}
onClick={() => setSelectedCategory(cat.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
selectedCategory === cat.id
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-accent'
}`}
>
<Icon size={16} />
{cat.label}
</button>
);
})}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
<button
onClick={() => setShowCustomSize(!showCustomSize)}
className="group flex flex-col items-center justify-center p-6 rounded-xl border-2 border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all aspect-square"
>
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-3 group-hover:bg-primary/20 transition-colors">
<Plus size={24} className="text-primary" />
</div>
<span className="text-sm font-medium text-foreground">Custom Size</span>
<span className="text-xs text-muted-foreground mt-1">Set dimensions</span>
</button>
{filteredPresets.map((preset) => (
<button
key={preset.name}
onClick={() => handleCreateProject(preset.width, preset.height, preset.name)}
className="group flex flex-col items-center justify-center p-6 rounded-xl border border-border bg-card hover:border-primary hover:shadow-md transition-all aspect-square"
>
<div
className="bg-muted rounded-lg mb-3 flex items-center justify-center"
style={{
width: Math.min(80, (preset.width / Math.max(preset.width, preset.height)) * 80),
height: Math.min(80, (preset.height / Math.max(preset.width, preset.height)) * 80),
}}
>
<Layout size={20} className="text-muted-foreground" />
</div>
<span className="text-sm font-medium text-foreground text-center">{preset.name}</span>
<span className="text-xs text-muted-foreground mt-1">
{preset.width} × {preset.height}
</span>
</button>
))}
</div>
</section>
{showCustomSize && (
<section className="mb-10 p-6 rounded-xl bg-card border border-border">
<h3 className="text-base font-medium text-foreground mb-4">Custom Dimensions</h3>
<div className="flex items-end gap-4">
<div>
<label className="block text-sm text-muted-foreground mb-2">Width (px)</label>
<input
type="number"
value={customWidth}
onChange={(e) => setCustomWidth(Number(e.target.value))}
className="w-32 px-3 py-2.5 rounded-lg bg-background border border-input text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
min={1}
max={8000}
/>
</div>
<span className="text-muted-foreground pb-2.5">×</span>
<div>
<label className="block text-sm text-muted-foreground mb-2">Height (px)</label>
<input
type="number"
value={customHeight}
onChange={(e) => setCustomHeight(Number(e.target.value))}
className="w-32 px-3 py-2.5 rounded-lg bg-background border border-input text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
min={1}
max={8000}
/>
</div>
<button
onClick={handleCreateCustom}
className="px-6 py-2.5 bg-primary text-primary-foreground rounded-lg text-sm font-medium hover:bg-primary/90 active:scale-[0.98] transition-all"
>
Create Design
</button>
</div>
</section>
)}
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">Recent Projects</h2>
{recentProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<FolderOpen size={28} className="text-muted-foreground" />
</div>
<p className="text-muted-foreground mb-2">No recent projects</p>
<p className="text-sm text-muted-foreground/70">
Create a new project to get started
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{recentProjects.map((project) => (
<div
key={project.id}
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card hover:border-primary hover:shadow-md transition-all cursor-pointer"
onClick={() => handleOpenProject(project.id)}
>
<div className="flex items-center justify-between mb-3">
<div
className="bg-muted rounded-lg flex items-center justify-center"
style={{
width: Math.min(60, (project.size.width / Math.max(project.size.width, project.size.height)) * 60),
height: Math.min(60, (project.size.height / Math.max(project.size.width, project.size.height)) * 60),
}}
>
<Layout size={16} className="text-muted-foreground" />
</div>
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setProjectMenuOpen(projectMenuOpen === project.id ? null : project.id);
}}
className="p-1.5 rounded-md opacity-0 group-hover:opacity-100 hover:bg-accent transition-all"
>
<MoreVertical size={16} className="text-muted-foreground" />
</button>
{projectMenuOpen === project.id && (
<div className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-lg border border-border bg-popover shadow-lg py-1">
<button
onClick={(e) => {
e.stopPropagation();
handleOpenProject(project.id);
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-accent transition-colors flex items-center gap-2"
>
<FolderOpen size={14} />
Open
</button>
<button
onClick={(e) => {
e.stopPropagation();
setDeleteConfirmId(project.id);
}}
className="w-full px-3 py-2 text-left text-sm text-destructive hover:bg-destructive/10 transition-colors flex items-center gap-2"
>
<Trash2 size={14} />
Delete
</button>
</div>
)}
</div>
</div>
<h3 className="text-sm font-medium text-foreground truncate mb-1">{project.name}</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock size={12} />
<span>{formatDate(project.updatedAt)}</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-1">
{project.size.width} × {project.size.height}
</p>
</div>
))}
</div>
)}
</section>
{deleteConfirmId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => setDeleteConfirmId(null)}
>
<div
className="bg-card border border-border rounded-xl p-6 max-w-sm mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-base font-semibold text-foreground mb-2">Delete Project?</h3>
<p className="text-sm text-muted-foreground mb-6">
This action cannot be undone. The project will be permanently deleted from your browser storage.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDeleteProject(deleteConfirmId)}
className="px-4 py-2 bg-destructive text-destructive-foreground rounded-lg text-sm font-medium hover:bg-destructive/90 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
</div>
<footer className="px-8 py-4 border-t border-border flex items-center justify-between">
<p className="text-xs text-muted-foreground">
OpenReel Image Professional graphic design in your browser
</p>
<p className="text-xs text-muted-foreground">
100% offline No account required
</p>
</footer>
</div>
);
}

View file

@ -0,0 +1,533 @@
export type BlendMode =
| 'normal'
| 'dissolve'
| 'darken'
| 'multiply'
| 'color-burn'
| 'linear-burn'
| 'darker-color'
| 'lighten'
| 'screen'
| 'color-dodge'
| 'linear-dodge'
| 'lighter-color'
| 'overlay'
| 'soft-light'
| 'hard-light'
| 'vivid-light'
| 'linear-light'
| 'pin-light'
| 'hard-mix'
| 'difference'
| 'exclusion'
| 'subtract'
| 'divide'
| 'hue'
| 'saturation'
| 'color'
| 'luminosity';
export interface BlendModeInfo {
name: string;
category: 'normal' | 'darken' | 'lighten' | 'contrast' | 'comparative' | 'component';
description: string;
}
export const BLEND_MODE_INFO: Record<BlendMode, BlendModeInfo> = {
normal: { name: 'Normal', category: 'normal', description: 'Edits or paints each pixel to make it the result color' },
dissolve: { name: 'Dissolve', category: 'normal', description: 'Randomly replaces pixels with the blend color' },
darken: { name: 'Darken', category: 'darken', description: 'Selects the darker of the base or blend color' },
multiply: { name: 'Multiply', category: 'darken', description: 'Multiplies the base color by the blend color' },
'color-burn': { name: 'Color Burn', category: 'darken', description: 'Darkens to increase the contrast' },
'linear-burn': { name: 'Linear Burn', category: 'darken', description: 'Darkens by decreasing the brightness' },
'darker-color': { name: 'Darker Color', category: 'darken', description: 'Compares total channel values' },
lighten: { name: 'Lighten', category: 'lighten', description: 'Selects the lighter of the base or blend color' },
screen: { name: 'Screen', category: 'lighten', description: 'Multiplies the inverse of the colors' },
'color-dodge': { name: 'Color Dodge', category: 'lighten', description: 'Brightens to decrease the contrast' },
'linear-dodge': { name: 'Linear Dodge (Add)', category: 'lighten', description: 'Brightens by increasing the brightness' },
'lighter-color': { name: 'Lighter Color', category: 'lighten', description: 'Compares total channel values' },
overlay: { name: 'Overlay', category: 'contrast', description: 'Multiplies or screens depending on the base color' },
'soft-light': { name: 'Soft Light', category: 'contrast', description: 'Darkens or lightens depending on the blend color' },
'hard-light': { name: 'Hard Light', category: 'contrast', description: 'Multiplies or screens depending on the blend color' },
'vivid-light': { name: 'Vivid Light', category: 'contrast', description: 'Burns or dodges by increasing or decreasing the contrast' },
'linear-light': { name: 'Linear Light', category: 'contrast', description: 'Burns or dodges by decreasing or increasing the brightness' },
'pin-light': { name: 'Pin Light', category: 'contrast', description: 'Replaces colors depending on the blend color' },
'hard-mix': { name: 'Hard Mix', category: 'contrast', description: 'Reduces colors to 8 colors' },
difference: { name: 'Difference', category: 'comparative', description: 'Subtracts the darker color from the lighter color' },
exclusion: { name: 'Exclusion', category: 'comparative', description: 'Similar to Difference but lower contrast' },
subtract: { name: 'Subtract', category: 'comparative', description: 'Subtracts the blend color from the base color' },
divide: { name: 'Divide', category: 'comparative', description: 'Divides the base color by the blend color' },
hue: { name: 'Hue', category: 'component', description: 'Creates a result with the hue of the blend color' },
saturation: { name: 'Saturation', category: 'component', description: 'Creates a result with the saturation of the blend color' },
color: { name: 'Color', category: 'component', description: 'Creates a result with the hue and saturation of the blend color' },
luminosity: { name: 'Luminosity', category: 'component', description: 'Creates a result with the luminosity of the blend color' },
};
function clamp(value: number): number {
return Math.max(0, Math.min(255, value));
}
function blendNormal(_base: number, blend: number): number {
return blend;
}
function blendDissolve(base: number, blend: number, opacity: number): number {
return Math.random() < opacity ? blend : base;
}
function blendDarken(base: number, blend: number): number {
return Math.min(base, blend);
}
function blendMultiply(base: number, blend: number): number {
return (base * blend) / 255;
}
function blendColorBurn(base: number, blend: number): number {
if (blend === 0) return 0;
return clamp(255 - ((255 - base) * 255) / blend);
}
function blendLinearBurn(base: number, blend: number): number {
return clamp(base + blend - 255);
}
function blendLighten(base: number, blend: number): number {
return Math.max(base, blend);
}
function blendScreen(base: number, blend: number): number {
return 255 - ((255 - base) * (255 - blend)) / 255;
}
function blendColorDodge(base: number, blend: number): number {
if (blend === 255) return 255;
return clamp((base * 255) / (255 - blend));
}
function blendLinearDodge(base: number, blend: number): number {
return clamp(base + blend);
}
function blendOverlay(base: number, blend: number): number {
if (base < 128) {
return (2 * base * blend) / 255;
}
return 255 - (2 * (255 - base) * (255 - blend)) / 255;
}
function blendSoftLight(base: number, blend: number): number {
if (blend < 128) {
return base - ((255 - 2 * blend) * base * (255 - base)) / (255 * 255);
}
const d = base < 64 ? ((16 * base - 12 * 255) * base + 4 * 255) * base / (255 * 255) : Math.sqrt(base / 255) * 255;
return base + ((2 * blend - 255) * (d - base)) / 255;
}
function blendHardLight(base: number, blend: number): number {
if (blend < 128) {
return (2 * base * blend) / 255;
}
return 255 - (2 * (255 - base) * (255 - blend)) / 255;
}
function blendVividLight(base: number, blend: number): number {
if (blend < 128) {
return blendColorBurn(base, 2 * blend);
}
return blendColorDodge(base, 2 * (blend - 128));
}
function blendLinearLight(base: number, blend: number): number {
if (blend < 128) {
return blendLinearBurn(base, 2 * blend);
}
return blendLinearDodge(base, 2 * (blend - 128));
}
function blendPinLight(base: number, blend: number): number {
if (blend < 128) {
return Math.min(base, 2 * blend);
}
return Math.max(base, 2 * (blend - 128));
}
function blendHardMix(base: number, blend: number): number {
return blendVividLight(base, blend) < 128 ? 0 : 255;
}
function blendDifference(base: number, blend: number): number {
return Math.abs(base - blend);
}
function blendExclusion(base: number, blend: number): number {
return base + blend - (2 * base * blend) / 255;
}
function blendSubtract(base: number, blend: number): number {
return clamp(base - blend);
}
function blendDivide(base: number, blend: number): number {
if (blend === 0) return 255;
return clamp((base * 256) / (blend + 1));
}
function rgbToHsl(r: number, g: number, b: number): [number, number, 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 [0, 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): [number, number, number] {
if (s === 0) {
const gray = Math.round(l * 255);
return [gray, gray, 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 [
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
];
}
function blendHue(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const [h] = rgbToHsl(blendR, blendG, blendB);
const [, s, l] = rgbToHsl(baseR, baseG, baseB);
return hslToRgb(h, s, l);
}
function blendSaturation(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const [h, , l] = rgbToHsl(baseR, baseG, baseB);
const [, s] = rgbToHsl(blendR, blendG, blendB);
return hslToRgb(h, s, l);
}
function blendColor(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const [h, s] = rgbToHsl(blendR, blendG, blendB);
const [, , l] = rgbToHsl(baseR, baseG, baseB);
return hslToRgb(h, s, l);
}
function blendLuminosity(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const [h, s] = rgbToHsl(baseR, baseG, baseB);
const [, , l] = rgbToHsl(blendR, blendG, blendB);
return hslToRgb(h, s, l);
}
function getLuminance(r: number, g: number, b: number): number {
return r * 0.299 + g * 0.587 + b * 0.114;
}
function blendDarkerColor(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const baseLum = getLuminance(baseR, baseG, baseB);
const blendLum = getLuminance(blendR, blendG, blendB);
return baseLum < blendLum ? [baseR, baseG, baseB] : [blendR, blendG, blendB];
}
function blendLighterColor(
baseR: number, baseG: number, baseB: number,
blendR: number, blendG: number, blendB: number
): [number, number, number] {
const baseLum = getLuminance(baseR, baseG, baseB);
const blendLum = getLuminance(blendR, blendG, blendB);
return baseLum > blendLum ? [baseR, baseG, baseB] : [blendR, blendG, blendB];
}
export function blendPixel(
baseR: number, baseG: number, baseB: number, baseA: number,
blendR: number, blendG: number, blendB: number, blendA: number,
mode: BlendMode,
opacity: number = 1
): [number, number, number, number] {
if (blendA === 0 || opacity === 0) {
return [baseR, baseG, baseB, baseA];
}
const effectiveOpacity = (blendA / 255) * opacity;
let resultR: number, resultG: number, resultB: number;
switch (mode) {
case 'normal':
resultR = blendNormal(baseR, blendR);
resultG = blendNormal(baseG, blendG);
resultB = blendNormal(baseB, blendB);
break;
case 'dissolve':
resultR = blendDissolve(baseR, blendR, effectiveOpacity);
resultG = blendDissolve(baseG, blendG, effectiveOpacity);
resultB = blendDissolve(baseB, blendB, effectiveOpacity);
return [resultR, resultG, resultB, baseA];
case 'darken':
resultR = blendDarken(baseR, blendR);
resultG = blendDarken(baseG, blendG);
resultB = blendDarken(baseB, blendB);
break;
case 'multiply':
resultR = blendMultiply(baseR, blendR);
resultG = blendMultiply(baseG, blendG);
resultB = blendMultiply(baseB, blendB);
break;
case 'color-burn':
resultR = blendColorBurn(baseR, blendR);
resultG = blendColorBurn(baseG, blendG);
resultB = blendColorBurn(baseB, blendB);
break;
case 'linear-burn':
resultR = blendLinearBurn(baseR, blendR);
resultG = blendLinearBurn(baseG, blendG);
resultB = blendLinearBurn(baseB, blendB);
break;
case 'darker-color':
[resultR, resultG, resultB] = blendDarkerColor(baseR, baseG, baseB, blendR, blendG, blendB);
break;
case 'lighten':
resultR = blendLighten(baseR, blendR);
resultG = blendLighten(baseG, blendG);
resultB = blendLighten(baseB, blendB);
break;
case 'screen':
resultR = blendScreen(baseR, blendR);
resultG = blendScreen(baseG, blendG);
resultB = blendScreen(baseB, blendB);
break;
case 'color-dodge':
resultR = blendColorDodge(baseR, blendR);
resultG = blendColorDodge(baseG, blendG);
resultB = blendColorDodge(baseB, blendB);
break;
case 'linear-dodge':
resultR = blendLinearDodge(baseR, blendR);
resultG = blendLinearDodge(baseG, blendG);
resultB = blendLinearDodge(baseB, blendB);
break;
case 'lighter-color':
[resultR, resultG, resultB] = blendLighterColor(baseR, baseG, baseB, blendR, blendG, blendB);
break;
case 'overlay':
resultR = blendOverlay(baseR, blendR);
resultG = blendOverlay(baseG, blendG);
resultB = blendOverlay(baseB, blendB);
break;
case 'soft-light':
resultR = blendSoftLight(baseR, blendR);
resultG = blendSoftLight(baseG, blendG);
resultB = blendSoftLight(baseB, blendB);
break;
case 'hard-light':
resultR = blendHardLight(baseR, blendR);
resultG = blendHardLight(baseG, blendG);
resultB = blendHardLight(baseB, blendB);
break;
case 'vivid-light':
resultR = blendVividLight(baseR, blendR);
resultG = blendVividLight(baseG, blendG);
resultB = blendVividLight(baseB, blendB);
break;
case 'linear-light':
resultR = blendLinearLight(baseR, blendR);
resultG = blendLinearLight(baseG, blendG);
resultB = blendLinearLight(baseB, blendB);
break;
case 'pin-light':
resultR = blendPinLight(baseR, blendR);
resultG = blendPinLight(baseG, blendG);
resultB = blendPinLight(baseB, blendB);
break;
case 'hard-mix':
resultR = blendHardMix(baseR, blendR);
resultG = blendHardMix(baseG, blendG);
resultB = blendHardMix(baseB, blendB);
break;
case 'difference':
resultR = blendDifference(baseR, blendR);
resultG = blendDifference(baseG, blendG);
resultB = blendDifference(baseB, blendB);
break;
case 'exclusion':
resultR = blendExclusion(baseR, blendR);
resultG = blendExclusion(baseG, blendG);
resultB = blendExclusion(baseB, blendB);
break;
case 'subtract':
resultR = blendSubtract(baseR, blendR);
resultG = blendSubtract(baseG, blendG);
resultB = blendSubtract(baseB, blendB);
break;
case 'divide':
resultR = blendDivide(baseR, blendR);
resultG = blendDivide(baseG, blendG);
resultB = blendDivide(baseB, blendB);
break;
case 'hue':
[resultR, resultG, resultB] = blendHue(baseR, baseG, baseB, blendR, blendG, blendB);
break;
case 'saturation':
[resultR, resultG, resultB] = blendSaturation(baseR, baseG, baseB, blendR, blendG, blendB);
break;
case 'color':
[resultR, resultG, resultB] = blendColor(baseR, baseG, baseB, blendR, blendG, blendB);
break;
case 'luminosity':
[resultR, resultG, resultB] = blendLuminosity(baseR, baseG, baseB, blendR, blendG, blendB);
break;
default:
resultR = blendR;
resultG = blendG;
resultB = blendB;
}
const finalR = clamp(baseR + (resultR - baseR) * effectiveOpacity);
const finalG = clamp(baseG + (resultG - baseG) * effectiveOpacity);
const finalB = clamp(baseB + (resultB - baseB) * effectiveOpacity);
const finalA = Math.max(baseA, Math.round(blendA * opacity));
return [finalR, finalG, finalB, finalA];
}
export function blendImageData(
base: ImageData,
blend: ImageData,
mode: BlendMode,
opacity: number = 1
): ImageData {
if (base.width !== blend.width || base.height !== blend.height) {
throw new Error('ImageData dimensions must match');
}
const result = new ImageData(base.width, base.height);
const baseData = base.data;
const blendData = blend.data;
const resultData = result.data;
for (let i = 0; i < baseData.length; i += 4) {
const [r, g, b, a] = blendPixel(
baseData[i], baseData[i + 1], baseData[i + 2], baseData[i + 3],
blendData[i], blendData[i + 1], blendData[i + 2], blendData[i + 3],
mode,
opacity
);
resultData[i] = r;
resultData[i + 1] = g;
resultData[i + 2] = b;
resultData[i + 3] = a;
}
return result;
}
export function getCompositeOperation(mode: BlendMode): GlobalCompositeOperation | null {
const modeMap: Partial<Record<BlendMode, GlobalCompositeOperation>> = {
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',
hue: 'hue',
saturation: 'saturation',
color: 'color',
luminosity: 'luminosity',
};
return modeMap[mode] ?? null;
}
export function requiresManualBlending(mode: BlendMode): boolean {
return getCompositeOperation(mode) === null;
}
export const BLEND_MODE_GROUPS = {
normal: ['normal', 'dissolve'] as BlendMode[],
darken: ['darken', 'multiply', 'color-burn', 'linear-burn', 'darker-color'] as BlendMode[],
lighten: ['lighten', 'screen', 'color-dodge', 'linear-dodge', 'lighter-color'] as BlendMode[],
contrast: ['overlay', 'soft-light', 'hard-light', 'vivid-light', 'linear-light', 'pin-light', 'hard-mix'] as BlendMode[],
comparative: ['difference', 'exclusion', 'subtract', 'divide'] as BlendMode[],
component: ['hue', 'saturation', 'color', 'luminosity'] as BlendMode[],
};

View file

@ -0,0 +1,742 @@
import { BlendMode, blendPixel } from './blend-modes';
export interface ContourPoint {
input: number;
output: number;
}
export interface ContourCurve {
points: ContourPoint[];
cornerAtPoint: boolean[];
}
export const DEFAULT_CONTOUR: ContourCurve = {
points: [
{ input: 0, output: 0 },
{ input: 255, output: 255 },
],
cornerAtPoint: [false, false],
};
export interface GradientStop {
position: number;
color: string;
opacity: number;
}
export interface GradientDef {
stops: GradientStop[];
type: 'linear' | 'radial';
angle?: number;
reverse?: boolean;
}
export interface PatternDef {
id: string;
name: string;
data: ImageData;
scale: number;
}
export interface BevelEmbossSettings {
enabled: boolean;
style: 'outer-bevel' | 'inner-bevel' | 'emboss' | 'pillow-emboss' | 'stroke-emboss';
technique: 'smooth' | 'chisel-hard' | 'chisel-soft';
depth: number;
direction: 'up' | 'down';
size: number;
soften: number;
angle: number;
altitude: number;
highlightMode: BlendMode;
highlightColor: string;
highlightOpacity: number;
shadowMode: BlendMode;
shadowColor: string;
shadowOpacity: number;
glossContour: ContourCurve;
contour: ContourCurve;
antiAlias: boolean;
}
export const DEFAULT_BEVEL_EMBOSS: BevelEmbossSettings = {
enabled: false,
style: 'inner-bevel',
technique: 'smooth',
depth: 100,
direction: 'up',
size: 5,
soften: 0,
angle: 120,
altitude: 30,
highlightMode: 'screen',
highlightColor: '#ffffff',
highlightOpacity: 75,
shadowMode: 'multiply',
shadowColor: '#000000',
shadowOpacity: 75,
glossContour: DEFAULT_CONTOUR,
contour: DEFAULT_CONTOUR,
antiAlias: true,
};
export interface InnerGlowSettings {
enabled: boolean;
blendMode: BlendMode;
opacity: number;
noise: number;
color: string;
gradient?: GradientDef;
technique: 'softer' | 'precise';
source: 'center' | 'edge';
choke: number;
size: number;
contour: ContourCurve;
antiAlias: boolean;
range: number;
jitter: number;
}
export const DEFAULT_INNER_GLOW: InnerGlowSettings = {
enabled: false,
blendMode: 'screen',
opacity: 75,
noise: 0,
color: '#ffffbe',
technique: 'softer',
source: 'edge',
choke: 0,
size: 5,
contour: DEFAULT_CONTOUR,
antiAlias: false,
range: 50,
jitter: 0,
};
export interface ColorOverlaySettings {
enabled: boolean;
blendMode: BlendMode;
color: string;
opacity: number;
}
export const DEFAULT_COLOR_OVERLAY: ColorOverlaySettings = {
enabled: false,
blendMode: 'normal',
color: '#ff0000',
opacity: 100,
};
export interface GradientOverlaySettings {
enabled: boolean;
blendMode: BlendMode;
opacity: number;
gradient: GradientDef;
style: 'linear' | 'radial' | 'angle' | 'reflected' | 'diamond';
alignWithLayer: boolean;
angle: number;
scale: number;
reverse: boolean;
dither: boolean;
}
export const DEFAULT_GRADIENT_OVERLAY: GradientOverlaySettings = {
enabled: false,
blendMode: 'normal',
opacity: 100,
gradient: {
stops: [
{ position: 0, color: '#000000', opacity: 100 },
{ position: 100, color: '#ffffff', opacity: 100 },
],
type: 'linear',
},
style: 'linear',
alignWithLayer: true,
angle: 90,
scale: 100,
reverse: false,
dither: false,
};
export interface PatternOverlaySettings {
enabled: boolean;
blendMode: BlendMode;
opacity: number;
pattern: PatternDef | null;
scale: number;
linkWithLayer: boolean;
}
export const DEFAULT_PATTERN_OVERLAY: PatternOverlaySettings = {
enabled: false,
blendMode: 'normal',
opacity: 100,
pattern: null,
scale: 100,
linkWithLayer: true,
};
export interface SatinSettings {
enabled: boolean;
blendMode: BlendMode;
color: string;
opacity: number;
angle: number;
distance: number;
size: number;
contour: ContourCurve;
antiAlias: boolean;
invert: boolean;
}
export const DEFAULT_SATIN: SatinSettings = {
enabled: false,
blendMode: 'multiply',
color: '#000000',
opacity: 50,
angle: 19,
distance: 11,
size: 14,
contour: DEFAULT_CONTOUR,
antiAlias: true,
invert: false,
};
export interface LayerStyles {
bevelEmboss: BevelEmbossSettings;
innerGlow: InnerGlowSettings;
colorOverlay: ColorOverlaySettings;
gradientOverlay: GradientOverlaySettings;
patternOverlay: PatternOverlaySettings;
satin: SatinSettings;
}
export const DEFAULT_LAYER_STYLES: LayerStyles = {
bevelEmboss: DEFAULT_BEVEL_EMBOSS,
innerGlow: DEFAULT_INNER_GLOW,
colorOverlay: DEFAULT_COLOR_OVERLAY,
gradientOverlay: DEFAULT_GRADIENT_OVERLAY,
patternOverlay: DEFAULT_PATTERN_OVERLAY,
satin: DEFAULT_SATIN,
};
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 evaluateContour(contour: ContourCurve, input: number): number {
const { points } = contour;
if (points.length === 0) return input;
input = Math.max(0, Math.min(255, input));
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
if (input >= p1.input && input <= p2.input) {
const t = (input - p1.input) / (p2.input - p1.input || 1);
return p1.output + (p2.output - p1.output) * t;
}
}
return points[points.length - 1].output;
}
function getEdgeDistance(
imageData: ImageData,
x: number,
y: number,
maxDistance: number,
fromEdge: boolean = true
): number {
const { width, height, data } = imageData;
const centerAlpha = data[(y * width + x) * 4 + 3];
if (fromEdge) {
if (centerAlpha === 0) return maxDistance;
} else {
if (centerAlpha === 255) return maxDistance;
}
let minDist = maxDistance;
for (let dy = -maxDistance; dy <= maxDistance; dy++) {
for (let dx = -maxDistance; dx <= maxDistance; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
const neighborAlpha = data[(ny * width + nx) * 4 + 3];
if (fromEdge ? neighborAlpha === 0 : neighborAlpha > 0) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < minDist) {
minDist = dist;
}
}
}
}
return minDist;
}
export function applyBevelEmboss(
ctx: OffscreenCanvasRenderingContext2D,
settings: BevelEmbossSettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const angleRad = (settings.angle * Math.PI) / 180;
const altitudeRad = (settings.altitude * Math.PI) / 180;
const lightX = Math.cos(angleRad) * Math.cos(altitudeRad);
const lightY = Math.sin(angleRad) * Math.cos(altitudeRad);
const highlightColor = parseColor(settings.highlightColor);
const shadowColor = parseColor(settings.shadowColor);
const size = settings.size;
const depth = settings.depth / 100;
const resultData = new Uint8ClampedArray(data);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const alpha = data[idx + 3];
if (alpha === 0) continue;
const edgeDist = getEdgeDistance(imageData, x, y, size, true);
if (edgeDist >= size) continue;
const bevelFactor = 1 - edgeDist / size;
const smoothedFactor = settings.technique === 'smooth'
? Math.sin(bevelFactor * Math.PI / 2)
: settings.technique === 'chisel-hard'
? bevelFactor > 0.5 ? 1 : 0
: bevelFactor;
const nx = x > 0 ? data[((y) * width + (x - 1)) * 4 + 3] - data[((y) * width + (x + 1)) * 4 + 3] : 0;
const ny = y > 0 ? data[((y - 1) * width + x) * 4 + 3] - data[((y + 1) * width + x) * 4 + 3] : 0;
const normalLen = Math.sqrt(nx * nx + ny * ny + 1);
const normalX = nx / normalLen;
const normalY = ny / normalLen;
let lighting = normalX * lightX + normalY * lightY;
lighting = settings.direction === 'down' ? -lighting : lighting;
lighting *= smoothedFactor * depth;
const contourValue = evaluateContour(settings.contour, Math.abs(lighting) * 255);
lighting = (lighting >= 0 ? 1 : -1) * (contourValue / 255);
if (lighting > 0) {
const opacity = lighting * (settings.highlightOpacity / 100);
const [r, g, b, a] = blendPixel(
resultData[idx], resultData[idx + 1], resultData[idx + 2], resultData[idx + 3],
highlightColor.r, highlightColor.g, highlightColor.b, Math.round(255 * opacity),
settings.highlightMode
);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
const opacity = -lighting * (settings.shadowOpacity / 100);
const [r, g, b, a] = blendPixel(
resultData[idx], resultData[idx + 1], resultData[idx + 2], resultData[idx + 3],
shadowColor.r, shadowColor.g, shadowColor.b, Math.round(255 * opacity),
settings.shadowMode
);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
}
}
}
const resultImage = new ImageData(resultData, width, height);
ctx.putImageData(resultImage, layerBounds.x, layerBounds.y);
}
export function applyInnerGlow(
ctx: OffscreenCanvasRenderingContext2D,
settings: InnerGlowSettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const glowColor = parseColor(settings.color);
const size = settings.size;
const choke = settings.choke / 100;
const resultData = new Uint8ClampedArray(data);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const alpha = data[idx + 3];
if (alpha === 0) continue;
const fromEdge = settings.source === 'edge';
const edgeDist = fromEdge
? getEdgeDistance(imageData, x, y, size, true)
: Math.min(
x, y,
width - x - 1, height - y - 1,
size
);
const effectiveSize = size * (1 - choke);
if (edgeDist >= effectiveSize) continue;
let intensity = 1 - edgeDist / effectiveSize;
if (settings.technique === 'softer') {
intensity = Math.sin(intensity * Math.PI / 2);
}
intensity = evaluateContour(settings.contour, intensity * 255) / 255;
if (settings.noise > 0) {
intensity *= 1 - (Math.random() * settings.noise / 100);
}
const opacity = intensity * (settings.opacity / 100);
const [r, g, b, a] = blendPixel(
resultData[idx], resultData[idx + 1], resultData[idx + 2], resultData[idx + 3],
glowColor.r, glowColor.g, glowColor.b, Math.round(255 * opacity),
settings.blendMode
);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
}
}
const resultImage = new ImageData(resultData, width, height);
ctx.putImageData(resultImage, layerBounds.x, layerBounds.y);
}
export function applyColorOverlay(
ctx: OffscreenCanvasRenderingContext2D,
settings: ColorOverlaySettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const overlayColor = parseColor(settings.color);
const opacity = settings.opacity / 100;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] === 0) continue;
const [r, g, b, a] = blendPixel(
data[i], data[i + 1], data[i + 2], data[i + 3],
overlayColor.r, overlayColor.g, overlayColor.b, Math.round(255 * opacity),
settings.blendMode
);
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = a;
}
ctx.putImageData(imageData, layerBounds.x, layerBounds.y);
}
export function applyGradientOverlay(
ctx: OffscreenCanvasRenderingContext2D,
settings: GradientOverlaySettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const angleRad = (settings.angle * Math.PI) / 180;
const centerX = width / 2;
const centerY = height / 2;
const diagonal = Math.sqrt(width * width + height * height);
const scale = settings.scale / 100;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] === 0) continue;
let gradientPos: number;
switch (settings.style) {
case 'linear': {
const dx = x - centerX;
const dy = y - centerY;
const projected = dx * Math.cos(angleRad) + dy * Math.sin(angleRad);
gradientPos = (projected / (diagonal * scale) + 0.5);
break;
}
case 'radial': {
const dx = (x - centerX) / scale;
const dy = (y - centerY) / scale;
gradientPos = Math.sqrt(dx * dx + dy * dy) / (diagonal / 2);
break;
}
case 'angle': {
const dx = x - centerX;
const dy = y - centerY;
gradientPos = (Math.atan2(dy, dx) + Math.PI) / (2 * Math.PI);
break;
}
case 'reflected': {
const dx = x - centerX;
const dy = y - centerY;
const projected = dx * Math.cos(angleRad) + dy * Math.sin(angleRad);
gradientPos = Math.abs(projected / (diagonal * scale / 2));
break;
}
case 'diamond': {
const dx = Math.abs(x - centerX) / scale;
const dy = Math.abs(y - centerY) / scale;
gradientPos = (dx + dy) / diagonal;
break;
}
default:
gradientPos = 0;
}
if (settings.reverse) {
gradientPos = 1 - gradientPos;
}
gradientPos = Math.max(0, Math.min(1, gradientPos));
const { r: gradR, g: gradG, b: gradB, a: gradA } = interpolateGradient(
settings.gradient,
gradientPos
);
const opacity = (settings.opacity / 100) * (gradA / 255);
const [r, g, b, a] = blendPixel(
data[idx], data[idx + 1], data[idx + 2], data[idx + 3],
gradR, gradG, gradB, Math.round(255 * opacity),
settings.blendMode
);
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = a;
}
}
ctx.putImageData(imageData, layerBounds.x, layerBounds.y);
}
function interpolateGradient(
gradient: GradientDef,
position: number
): { r: number; g: number; b: number; a: number } {
const { stops } = gradient;
if (stops.length === 0) return { r: 0, g: 0, b: 0, a: 255 };
const pos = position * 100;
let stop1 = stops[0];
let stop2 = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (pos >= stops[i].position && pos <= stops[i + 1].position) {
stop1 = stops[i];
stop2 = stops[i + 1];
break;
}
}
const t = stop1.position === stop2.position
? 0
: (pos - 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),
a: Math.round((stop1.opacity + (stop2.opacity - stop1.opacity) * t) * 2.55),
};
}
export function applyPatternOverlay(
ctx: OffscreenCanvasRenderingContext2D,
settings: PatternOverlaySettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled || !settings.pattern) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const pattern = settings.pattern;
const patternData = pattern.data.data;
const patternWidth = pattern.data.width;
const patternHeight = pattern.data.height;
const scale = (settings.scale / 100) * pattern.scale;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] === 0) continue;
const px = Math.floor((x / scale) % patternWidth);
const py = Math.floor((y / scale) % patternHeight);
const pIdx = (py * patternWidth + px) * 4;
const opacity = (settings.opacity / 100) * (patternData[pIdx + 3] / 255);
const [r, g, b, a] = blendPixel(
data[idx], data[idx + 1], data[idx + 2], data[idx + 3],
patternData[pIdx], patternData[pIdx + 1], patternData[pIdx + 2], Math.round(255 * opacity),
settings.blendMode
);
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = a;
}
}
ctx.putImageData(imageData, layerBounds.x, layerBounds.y);
}
export function applySatin(
ctx: OffscreenCanvasRenderingContext2D,
settings: SatinSettings,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (!settings.enabled) return;
const { width, height } = layerBounds;
const imageData = ctx.getImageData(layerBounds.x, layerBounds.y, width, height);
const data = imageData.data;
const satinColor = parseColor(settings.color);
const angleRad = (settings.angle * Math.PI) / 180;
const offsetX = Math.cos(angleRad) * settings.distance;
const offsetY = Math.sin(angleRad) * settings.distance;
const resultData = new Uint8ClampedArray(data);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
if (data[idx + 3] === 0) continue;
const x1 = Math.round(x + offsetX);
const y1 = Math.round(y + offsetY);
const x2 = Math.round(x - offsetX);
const y2 = Math.round(y - offsetY);
let alpha1 = 0;
let alpha2 = 0;
if (x1 >= 0 && x1 < width && y1 >= 0 && y1 < height) {
alpha1 = data[(y1 * width + x1) * 4 + 3];
}
if (x2 >= 0 && x2 < width && y2 >= 0 && y2 < height) {
alpha2 = data[(y2 * width + x2) * 4 + 3];
}
let satinIntensity = Math.abs(alpha1 - alpha2) / 255;
const dist1 = getEdgeDistance(imageData, x, y, settings.size, true);
const distFactor = 1 - Math.min(dist1, settings.size) / settings.size;
satinIntensity *= distFactor;
satinIntensity = evaluateContour(settings.contour, satinIntensity * 255) / 255;
if (settings.invert) {
satinIntensity = 1 - satinIntensity;
}
const opacity = satinIntensity * (settings.opacity / 100);
const [r, g, b, a] = blendPixel(
resultData[idx], resultData[idx + 1], resultData[idx + 2], resultData[idx + 3],
satinColor.r, satinColor.g, satinColor.b, Math.round(255 * opacity),
settings.blendMode
);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
}
}
const resultImage = new ImageData(resultData, width, height);
ctx.putImageData(resultImage, layerBounds.x, layerBounds.y);
}
export function applyLayerStyles(
ctx: OffscreenCanvasRenderingContext2D,
styles: Partial<LayerStyles>,
layerBounds: { x: number; y: number; width: number; height: number }
): void {
if (styles.patternOverlay) {
applyPatternOverlay(ctx, { ...DEFAULT_PATTERN_OVERLAY, ...styles.patternOverlay }, layerBounds);
}
if (styles.gradientOverlay) {
applyGradientOverlay(ctx, { ...DEFAULT_GRADIENT_OVERLAY, ...styles.gradientOverlay }, layerBounds);
}
if (styles.colorOverlay) {
applyColorOverlay(ctx, { ...DEFAULT_COLOR_OVERLAY, ...styles.colorOverlay }, layerBounds);
}
if (styles.satin) {
applySatin(ctx, { ...DEFAULT_SATIN, ...styles.satin }, layerBounds);
}
if (styles.innerGlow) {
applyInnerGlow(ctx, { ...DEFAULT_INNER_GLOW, ...styles.innerGlow }, layerBounds);
}
if (styles.bevelEmboss) {
applyBevelEmboss(ctx, { ...DEFAULT_BEVEL_EMBOSS, ...styles.bevelEmboss }, layerBounds);
}
}

View file

@ -0,0 +1,471 @@
export interface GaussianBlurSettings {
radius: number;
}
export interface MotionBlurSettings {
angle: number;
distance: number;
}
export interface RadialBlurSettings {
amount: number;
method: 'spin' | 'zoom';
quality: 'draft' | 'better' | 'best';
centerX: number;
centerY: number;
}
export interface LensBlurSettings {
radius: number;
irisShape: number;
irisRotation: number;
irisCurvature: number;
highlightBrightness: number;
highlightThreshold: number;
}
export interface SurfaceBlurSettings {
radius: number;
threshold: number;
}
export interface TiltShiftSettings {
blur: number;
focusY: number;
focusHeight: number;
transitionSize: number;
angle: number;
}
export const DEFAULT_GAUSSIAN_BLUR: GaussianBlurSettings = {
radius: 5,
};
export const DEFAULT_MOTION_BLUR: MotionBlurSettings = {
angle: 0,
distance: 10,
};
export const DEFAULT_RADIAL_BLUR: RadialBlurSettings = {
amount: 10,
method: 'spin',
quality: 'better',
centerX: 0.5,
centerY: 0.5,
};
export const DEFAULT_LENS_BLUR: LensBlurSettings = {
radius: 15,
irisShape: 6,
irisRotation: 0,
irisCurvature: 0,
highlightBrightness: 0,
highlightThreshold: 255,
};
export const DEFAULT_SURFACE_BLUR: SurfaceBlurSettings = {
radius: 5,
threshold: 15,
};
export const DEFAULT_TILT_SHIFT: TiltShiftSettings = {
blur: 15,
focusY: 0.5,
focusHeight: 0.2,
transitionSize: 0.1,
angle: 0,
};
function createGaussianKernel(radius: number): number[] {
const size = radius * 2 + 1;
const kernel = new Array(size);
const sigma = radius / 3;
const twoSigmaSquare = 2 * sigma * sigma;
let sum = 0;
for (let i = 0; i < size; i++) {
const x = i - radius;
kernel[i] = Math.exp(-(x * x) / twoSigmaSquare);
sum += kernel[i];
}
for (let i = 0; i < size; i++) {
kernel[i] /= sum;
}
return kernel;
}
export function applyGaussianBlur(imageData: ImageData, settings: GaussianBlurSettings): ImageData {
const { width, height, data } = imageData;
const radius = Math.max(1, Math.round(settings.radius));
const kernel = createGaussianKernel(radius);
const tempData = new Uint8ClampedArray(data);
const resultData = new Uint8ClampedArray(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let k = -radius; k <= radius; k++) {
const sx = Math.min(Math.max(x + k, 0), width - 1);
const idx = (y * width + sx) * 4;
const weight = kernel[k + radius];
r += data[idx] * weight;
g += data[idx + 1] * weight;
b += data[idx + 2] * weight;
a += data[idx + 3] * weight;
}
const idx = (y * width + x) * 4;
tempData[idx] = r;
tempData[idx + 1] = g;
tempData[idx + 2] = b;
tempData[idx + 3] = a;
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let k = -radius; k <= radius; k++) {
const sy = Math.min(Math.max(y + k, 0), height - 1);
const idx = (sy * width + x) * 4;
const weight = kernel[k + radius];
r += tempData[idx] * weight;
g += tempData[idx + 1] * weight;
b += tempData[idx + 2] * weight;
a += tempData[idx + 3] * weight;
}
const idx = (y * width + x) * 4;
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
}
}
return new ImageData(resultData, width, height);
}
export function applyMotionBlur(imageData: ImageData, settings: MotionBlurSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const angleRad = (settings.angle * Math.PI) / 180;
const dx = Math.cos(angleRad);
const dy = Math.sin(angleRad);
const samples = Math.max(1, Math.round(settings.distance));
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
let count = 0;
for (let i = -samples; i <= samples; i++) {
const sx = Math.round(x + dx * i);
const sy = Math.round(y + dy * i);
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const idx = (sy * width + sx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
a += data[idx + 3];
count++;
}
}
const idx = (y * width + x) * 4;
resultData[idx] = r / count;
resultData[idx + 1] = g / count;
resultData[idx + 2] = b / count;
resultData[idx + 3] = a / count;
}
}
return new ImageData(resultData, width, height);
}
export function applyRadialBlur(imageData: ImageData, settings: RadialBlurSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width * settings.centerX;
const centerY = height * settings.centerY;
const qualitySamples = settings.quality === 'draft' ? 8 : settings.quality === 'better' ? 16 : 32;
const amount = settings.amount / 100;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
for (let i = 0; i < qualitySamples; i++) {
const t = (i / qualitySamples - 0.5) * amount;
let sx: number, sy: number;
if (settings.method === 'spin') {
const newAngle = angle + t;
sx = Math.round(centerX + Math.cos(newAngle) * dist);
sy = Math.round(centerY + Math.sin(newAngle) * dist);
} else {
const scale = 1 + t;
sx = Math.round(centerX + dx * scale);
sy = Math.round(centerY + dy * scale);
}
sx = Math.min(Math.max(sx, 0), width - 1);
sy = Math.min(Math.max(sy, 0), height - 1);
const idx = (sy * width + sx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
a += data[idx + 3];
}
const idx = (y * width + x) * 4;
resultData[idx] = r / qualitySamples;
resultData[idx + 1] = g / qualitySamples;
resultData[idx + 2] = b / qualitySamples;
resultData[idx + 3] = a / qualitySamples;
}
}
return new ImageData(resultData, width, height);
}
function createBokehKernel(radius: number, shape: number, rotation: number): Array<{ x: number; y: number; weight: number }> {
const kernel: Array<{ x: number; y: number; weight: number }> = [];
const rotRad = (rotation * Math.PI) / 180;
const safeShape = Math.max(3, Math.round(shape));
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
const rx = x * Math.cos(rotRad) - y * Math.sin(rotRad);
const ry = x * Math.sin(rotRad) + y * Math.cos(rotRad);
const angle = Math.atan2(ry, rx);
const angleStep = (2 * Math.PI) / safeShape;
const cosValue = Math.cos((angle % angleStep) - angleStep / 2);
const polygonRadius = Math.abs(cosValue) > 0.001 ? radius / cosValue : radius;
const dist = Math.sqrt(rx * rx + ry * ry);
if (dist <= Math.abs(polygonRadius)) {
kernel.push({ x, y, weight: 1 });
}
}
}
if (kernel.length === 0) {
kernel.push({ x: 0, y: 0, weight: 1 });
} else {
const totalWeight = kernel.length;
kernel.forEach(k => k.weight /= totalWeight);
}
return kernel;
}
export function applyLensBlur(imageData: ImageData, settings: LensBlurSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const radius = Math.max(1, Math.round(settings.radius));
const kernel = createBokehKernel(radius, settings.irisShape, settings.irisRotation);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
let totalWeight = 0;
for (const k of kernel) {
const sx = Math.min(Math.max(x + k.x, 0), width - 1);
const sy = Math.min(Math.max(y + k.y, 0), height - 1);
const idx = (sy * width + sx) * 4;
const luminance = (data[idx] * 0.299 + data[idx + 1] * 0.587 + data[idx + 2] * 0.114);
let weight = k.weight;
if (luminance > settings.highlightThreshold && settings.highlightThreshold < 255) {
weight *= 1 + (settings.highlightBrightness / 100) * ((luminance - settings.highlightThreshold) / (255 - settings.highlightThreshold));
}
r += data[idx] * weight;
g += data[idx + 1] * weight;
b += data[idx + 2] * weight;
a += data[idx + 3] * weight;
totalWeight += weight;
}
const idx = (y * width + x) * 4;
if (totalWeight > 0) {
resultData[idx] = Math.min(255, r / totalWeight);
resultData[idx + 1] = Math.min(255, g / totalWeight);
resultData[idx + 2] = Math.min(255, b / totalWeight);
resultData[idx + 3] = a / totalWeight;
} else {
resultData[idx] = data[idx];
resultData[idx + 1] = data[idx + 1];
resultData[idx + 2] = data[idx + 2];
resultData[idx + 3] = data[idx + 3];
}
}
}
return new ImageData(resultData, width, height);
}
export function applySurfaceBlur(imageData: ImageData, settings: SurfaceBlurSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const radius = Math.max(1, Math.round(settings.radius));
const threshold = settings.threshold;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const centerIdx = (y * width + x) * 4;
const centerR = data[centerIdx];
const centerG = data[centerIdx + 1];
const centerB = data[centerIdx + 2];
let r = 0, g = 0, b = 0, a = 0;
let totalWeight = 0;
for (let ky = -radius; ky <= radius; ky++) {
for (let kx = -radius; kx <= radius; kx++) {
const sx = Math.min(Math.max(x + kx, 0), width - 1);
const sy = Math.min(Math.max(y + ky, 0), height - 1);
const idx = (sy * width + sx) * 4;
const diff = Math.abs(data[idx] - centerR) +
Math.abs(data[idx + 1] - centerG) +
Math.abs(data[idx + 2] - centerB);
const colorWeight = Math.max(0, 1 - diff / (threshold * 3));
const spatialWeight = 1 / (1 + Math.sqrt(kx * kx + ky * ky));
const weight = colorWeight * spatialWeight;
r += data[idx] * weight;
g += data[idx + 1] * weight;
b += data[idx + 2] * weight;
a += data[idx + 3] * weight;
totalWeight += weight;
}
}
resultData[centerIdx] = r / totalWeight;
resultData[centerIdx + 1] = g / totalWeight;
resultData[centerIdx + 2] = b / totalWeight;
resultData[centerIdx + 3] = a / totalWeight;
}
}
return new ImageData(resultData, width, height);
}
export function applyTiltShift(imageData: ImageData, settings: TiltShiftSettings): ImageData {
const { width, height, data } = imageData;
const blurredData = applyGaussianBlur(imageData, { radius: settings.blur });
const blurData = blurredData.data;
const resultData = new Uint8ClampedArray(data.length);
const focusCenter = height * settings.focusY;
const focusHalfHeight = (height * settings.focusHeight) / 2;
const transitionSize = height * settings.transitionSize;
const angleRad = (settings.angle * Math.PI) / 180;
const centerX = width / 2;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - centerX;
const adjustedY = y + Math.tan(angleRad) * dx;
const distFromCenter = Math.abs(adjustedY - focusCenter);
let blurAmount: number;
if (distFromCenter < focusHalfHeight) {
blurAmount = 0;
} else if (distFromCenter < focusHalfHeight + transitionSize) {
blurAmount = (distFromCenter - focusHalfHeight) / transitionSize;
blurAmount = blurAmount * blurAmount;
} else {
blurAmount = 1;
}
const idx = (y * width + x) * 4;
resultData[idx] = data[idx] * (1 - blurAmount) + blurData[idx] * blurAmount;
resultData[idx + 1] = data[idx + 1] * (1 - blurAmount) + blurData[idx + 1] * blurAmount;
resultData[idx + 2] = data[idx + 2] * (1 - blurAmount) + blurData[idx + 2] * blurAmount;
resultData[idx + 3] = data[idx + 3];
}
}
return new ImageData(resultData, width, height);
}
export function applyBoxBlur(imageData: ImageData, radius: number): ImageData {
const { width, height, data } = imageData;
const size = radius * 2 + 1;
const tempData = new Uint8ClampedArray(data);
const resultData = new Uint8ClampedArray(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let k = -radius; k <= radius; k++) {
const sx = Math.min(Math.max(x + k, 0), width - 1);
const idx = (y * width + sx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
a += data[idx + 3];
}
const idx = (y * width + x) * 4;
tempData[idx] = r / size;
tempData[idx + 1] = g / size;
tempData[idx + 2] = b / size;
tempData[idx + 3] = a / size;
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, a = 0;
for (let k = -radius; k <= radius; k++) {
const sy = Math.min(Math.max(y + k, 0), height - 1);
const idx = (sy * width + x) * 4;
r += tempData[idx];
g += tempData[idx + 1];
b += tempData[idx + 2];
a += tempData[idx + 3];
}
const idx = (y * width + x) * 4;
resultData[idx] = r / size;
resultData[idx + 1] = g / size;
resultData[idx + 2] = b / size;
resultData[idx + 3] = a / size;
}
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,483 @@
export interface SpherizeSettings {
amount: number;
mode: 'normal' | 'horizontal' | 'vertical';
centerX: number;
centerY: number;
}
export interface PinchSettings {
amount: number;
centerX: number;
centerY: number;
radius: number;
}
export interface TwirlSettings {
angle: number;
centerX: number;
centerY: number;
radius: number;
}
export interface WaveSettings {
generators: number;
wavelengthMin: number;
wavelengthMax: number;
amplitudeMin: number;
amplitudeMax: number;
scaleX: number;
scaleY: number;
type: 'sine' | 'triangle' | 'square';
wrapAround: boolean;
}
export interface RippleSettings {
amount: number;
size: 'small' | 'medium' | 'large';
}
export interface ZigZagSettings {
amount: number;
ridges: number;
style: 'around-center' | 'out-from-center' | 'pond-ripples';
centerX: number;
centerY: number;
}
export interface PolarCoordinatesSettings {
mode: 'rectangular-to-polar' | 'polar-to-rectangular';
}
export const DEFAULT_SPHERIZE: SpherizeSettings = {
amount: 100,
mode: 'normal',
centerX: 0.5,
centerY: 0.5,
};
export const DEFAULT_PINCH: PinchSettings = {
amount: 50,
centerX: 0.5,
centerY: 0.5,
radius: 0.5,
};
export const DEFAULT_TWIRL: TwirlSettings = {
angle: 50,
centerX: 0.5,
centerY: 0.5,
radius: 0.5,
};
export const DEFAULT_WAVE: WaveSettings = {
generators: 5,
wavelengthMin: 10,
wavelengthMax: 120,
amplitudeMin: 5,
amplitudeMax: 35,
scaleX: 100,
scaleY: 100,
type: 'sine',
wrapAround: true,
};
export const DEFAULT_RIPPLE: RippleSettings = {
amount: 100,
size: 'medium',
};
export const DEFAULT_ZIGZAG: ZigZagSettings = {
amount: 100,
ridges: 5,
style: 'pond-ripples',
centerX: 0.5,
centerY: 0.5,
};
export const DEFAULT_POLAR_COORDINATES: PolarCoordinatesSettings = {
mode: 'rectangular-to-polar',
};
function bilinearSample(
data: Uint8ClampedArray,
width: number,
height: number,
x: number,
y: number
): [number, number, number, number] {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const x1 = Math.min(x0 + 1, width - 1);
const y1 = Math.min(y0 + 1, height - 1);
const fx = x - x0;
const fy = y - y0;
const idx00 = (y0 * width + x0) * 4;
const idx10 = (y0 * width + x1) * 4;
const idx01 = (y1 * width + x0) * 4;
const idx11 = (y1 * width + x1) * 4;
const result: [number, number, number, number] = [0, 0, 0, 0];
for (let c = 0; c < 4; c++) {
const v00 = data[idx00 + c];
const v10 = data[idx10 + c];
const v01 = data[idx01 + c];
const v11 = data[idx11 + c];
result[c] = (1 - fx) * (1 - fy) * v00 +
fx * (1 - fy) * v10 +
(1 - fx) * fy * v01 +
fx * fy * v11;
}
return result;
}
export function applySpherize(imageData: ImageData, settings: SpherizeSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width * settings.centerX;
const centerY = height * settings.centerY;
const radius = Math.min(width, height) / 2;
const amount = settings.amount / 100;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let dx = (x - centerX) / radius;
let dy = (y - centerY) / radius;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
const sphereDist = Math.sqrt(1 - dist * dist);
const factor = (1 - sphereDist) * amount + (1 - amount);
if (settings.mode === 'normal' || settings.mode === 'horizontal') {
dx *= factor;
}
if (settings.mode === 'normal' || settings.mode === 'vertical') {
dy *= factor;
}
}
const sx = centerX + dx * radius;
const sy = centerY + dy * radius;
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}
export function applyPinch(imageData: ImageData, settings: PinchSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width * settings.centerX;
const centerY = height * settings.centerY;
const radius = Math.min(width, height) * settings.radius;
const amount = settings.amount / 100;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
let sx = x, sy = y;
if (dist < radius) {
const normalizedDist = dist / radius;
const pinchFactor = Math.pow(Math.sin(normalizedDist * Math.PI / 2), -amount);
sx = centerX + dx * pinchFactor;
sy = centerY + dy * pinchFactor;
}
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}
export function applyTwirl(imageData: ImageData, settings: TwirlSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width * settings.centerX;
const centerY = height * settings.centerY;
const radius = Math.min(width, height) * settings.radius;
const angleRad = (settings.angle * Math.PI) / 180;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
let sx = x, sy = y;
if (dist < radius) {
const angle = Math.atan2(dy, dx);
const normalizedDist = dist / radius;
const twirlAngle = angle + angleRad * (1 - normalizedDist);
sx = centerX + Math.cos(twirlAngle) * dist;
sy = centerY + Math.sin(twirlAngle) * dist;
}
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}
export function applyWave(imageData: ImageData, settings: WaveSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const generators: Array<{
wavelength: number;
amplitude: number;
phase: number;
}> = [];
for (let i = 0; i < settings.generators; i++) {
const t = settings.generators > 1 ? i / (settings.generators - 1) : 0;
generators.push({
wavelength: settings.wavelengthMin + (settings.wavelengthMax - settings.wavelengthMin) * t,
amplitude: settings.amplitudeMin + (settings.amplitudeMax - settings.amplitudeMin) * t,
phase: Math.random() * Math.PI * 2,
});
}
const waveFunc = (value: number): number => {
switch (settings.type) {
case 'triangle':
return 2 * Math.abs(2 * (value / (Math.PI * 2) - Math.floor(value / (Math.PI * 2) + 0.5))) - 1;
case 'square':
return Math.sin(value) >= 0 ? 1 : -1;
case 'sine':
default:
return Math.sin(value);
}
};
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let offsetX = 0;
let offsetY = 0;
for (const gen of generators) {
offsetX += waveFunc(y / gen.wavelength * Math.PI * 2 + gen.phase) * gen.amplitude;
offsetY += waveFunc(x / gen.wavelength * Math.PI * 2 + gen.phase) * gen.amplitude;
}
offsetX *= settings.scaleX / 100;
offsetY *= settings.scaleY / 100;
let sx = x + offsetX;
let sy = y + offsetY;
if (settings.wrapAround) {
sx = ((sx % width) + width) % width;
sy = ((sy % height) + height) % height;
}
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}
export function applyRipple(imageData: ImageData, settings: RippleSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const sizeMap = { small: 10, medium: 25, large: 50 };
const wavelength = sizeMap[settings.size];
const amplitude = settings.amount / 100 * 10;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offsetX = Math.sin(y / wavelength * Math.PI * 2) * amplitude;
const offsetY = Math.sin(x / wavelength * Math.PI * 2) * amplitude;
const sx = Math.min(Math.max(x + offsetX, 0), width - 1);
const sy = Math.min(Math.max(y + offsetY, 0), height - 1);
const idx = (y * width + x) * 4;
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
}
}
return new ImageData(resultData, width, height);
}
export function applyZigZag(imageData: ImageData, settings: ZigZagSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width * settings.centerX;
const centerY = height * settings.centerY;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
const amount = settings.amount / 100 * 20;
const ridges = settings.ridges;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const dx = x - centerX;
const dy = y - centerY;
const dist = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
let offset = 0;
switch (settings.style) {
case 'around-center':
offset = Math.sin(angle * ridges) * amount * (dist / maxRadius);
break;
case 'out-from-center':
offset = Math.sin(dist / maxRadius * ridges * Math.PI) * amount;
break;
case 'pond-ripples':
offset = Math.sin(dist / maxRadius * ridges * Math.PI * 2) * amount * (1 - dist / maxRadius);
break;
}
const sx = centerX + (dx + Math.cos(angle) * offset);
const sy = centerY + (dy + Math.sin(angle) * offset);
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}
export function applyPolarCoordinates(imageData: ImageData, settings: PolarCoordinatesSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const centerX = width / 2;
const centerY = height / 2;
const maxRadius = Math.sqrt(centerX * centerX + centerY * centerY);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sx: number, sy: number;
if (settings.mode === 'rectangular-to-polar') {
const normalizedX = x / width;
const normalizedY = y / height;
const angle = normalizedX * Math.PI * 2;
const radius = normalizedY * maxRadius;
sx = centerX + Math.cos(angle) * radius;
sy = centerY + Math.sin(angle) * radius;
} else {
const dx = x - centerX;
const dy = y - centerY;
const radius = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx);
sx = ((angle + Math.PI) / (Math.PI * 2)) * width;
sy = (radius / maxRadius) * height;
}
const idx = (y * width + x) * 4;
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const [r, g, b, a] = bilinearSample(data, width, height, sx, sy);
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = a;
} else {
resultData[idx] = 0;
resultData[idx + 1] = 0;
resultData[idx + 2] = 0;
resultData[idx + 3] = 0;
}
}
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,285 @@
export interface UnsharpMaskSettings {
amount: number;
radius: number;
threshold: number;
}
export interface SmartSharpenSettings {
amount: number;
radius: number;
removeBlur: 'gaussian' | 'lens' | 'motion';
motionAngle?: number;
noiseReduction: number;
}
export interface HighPassSettings {
radius: number;
}
export const DEFAULT_UNSHARP_MASK: UnsharpMaskSettings = {
amount: 50,
radius: 1,
threshold: 0,
};
export const DEFAULT_SMART_SHARPEN: SmartSharpenSettings = {
amount: 100,
radius: 1,
removeBlur: 'gaussian',
noiseReduction: 0,
};
export const DEFAULT_HIGH_PASS: HighPassSettings = {
radius: 10,
};
function createGaussianKernel(radius: number): number[] {
const size = radius * 2 + 1;
const kernel = new Array(size);
const sigma = radius / 3;
const twoSigmaSquare = 2 * sigma * sigma;
let sum = 0;
for (let i = 0; i < size; i++) {
const x = i - radius;
kernel[i] = Math.exp(-(x * x) / twoSigmaSquare);
sum += kernel[i];
}
for (let i = 0; i < size; i++) {
kernel[i] /= sum;
}
return kernel;
}
function gaussianBlur(data: Uint8ClampedArray, width: number, height: number, radius: number): Uint8ClampedArray {
const kernel = createGaussianKernel(radius);
const tempData = new Uint8ClampedArray(data);
const resultData = new Uint8ClampedArray(data.length);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
for (let k = -radius; k <= radius; k++) {
const sx = Math.min(Math.max(x + k, 0), width - 1);
const idx = (y * width + sx) * 4;
const weight = kernel[k + radius];
r += data[idx] * weight;
g += data[idx + 1] * weight;
b += data[idx + 2] * weight;
}
const idx = (y * width + x) * 4;
tempData[idx] = r;
tempData[idx + 1] = g;
tempData[idx + 2] = b;
tempData[idx + 3] = data[idx + 3];
}
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
for (let k = -radius; k <= radius; k++) {
const sy = Math.min(Math.max(y + k, 0), height - 1);
const idx = (sy * width + x) * 4;
const weight = kernel[k + radius];
r += tempData[idx] * weight;
g += tempData[idx + 1] * weight;
b += tempData[idx + 2] * weight;
}
const idx = (y * width + x) * 4;
resultData[idx] = r;
resultData[idx + 1] = g;
resultData[idx + 2] = b;
resultData[idx + 3] = tempData[idx + 3];
}
}
return resultData;
}
export function applyUnsharpMask(imageData: ImageData, settings: UnsharpMaskSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const radius = Math.max(1, Math.round(settings.radius));
const amount = settings.amount / 100;
const threshold = settings.threshold;
const blurredData = gaussianBlur(data, width, height, radius);
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const original = data[i + c];
const blurred = blurredData[i + c];
const diff = original - blurred;
if (Math.abs(diff) >= threshold) {
resultData[i + c] = Math.max(0, Math.min(255, original + diff * amount));
} else {
resultData[i + c] = original;
}
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
function motionBlur(data: Uint8ClampedArray, width: number, height: number, radius: number, angle: number): Uint8ClampedArray {
const resultData = new Uint8ClampedArray(data.length);
const angleRad = (angle * Math.PI) / 180;
const dx = Math.cos(angleRad);
const dy = Math.sin(angleRad);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0;
let count = 0;
for (let k = -radius; k <= radius; k++) {
const sx = Math.round(x + dx * k);
const sy = Math.round(y + dy * k);
if (sx >= 0 && sx < width && sy >= 0 && sy < height) {
const idx = (sy * width + sx) * 4;
r += data[idx];
g += data[idx + 1];
b += data[idx + 2];
count++;
}
}
const idx = (y * width + x) * 4;
resultData[idx] = r / count;
resultData[idx + 1] = g / count;
resultData[idx + 2] = b / count;
resultData[idx + 3] = data[idx + 3];
}
}
return resultData;
}
export function applySmartSharpen(imageData: ImageData, settings: SmartSharpenSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const radius = Math.max(1, Math.round(settings.radius));
const amount = settings.amount / 100;
const noiseReduction = settings.noiseReduction / 100;
let blurredData: Uint8ClampedArray;
switch (settings.removeBlur) {
case 'motion':
blurredData = motionBlur(data, width, height, radius, settings.motionAngle ?? 0);
break;
case 'lens':
blurredData = gaussianBlur(data, width, height, radius);
break;
case 'gaussian':
default:
blurredData = gaussianBlur(data, width, height, radius);
break;
}
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const original = data[i + c];
const blurred = blurredData[i + c];
let diff = original - blurred;
if (noiseReduction > 0) {
const absDiff = Math.abs(diff);
if (absDiff < 10 * noiseReduction) {
diff *= 1 - noiseReduction;
}
}
resultData[i + c] = Math.max(0, Math.min(255, original + diff * amount));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
export function applyHighPass(imageData: ImageData, settings: HighPassSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const radius = Math.max(1, Math.round(settings.radius));
const blurredData = gaussianBlur(data, width, height, radius);
for (let i = 0; i < data.length; i += 4) {
for (let c = 0; c < 3; c++) {
const original = data[i + c];
const blurred = blurredData[i + c];
resultData[i + c] = Math.max(0, Math.min(255, 128 + (original - blurred)));
}
resultData[i + 3] = data[i + 3];
}
return new ImageData(resultData, width, height);
}
export function applySharpen(imageData: ImageData, amount: number = 50): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const factor = amount / 100;
const kernel = [
0, -1, 0,
-1, 5, -1,
0, -1, 0,
];
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
for (let c = 0; c < 3; c++) {
let sum = 0;
let ki = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const idx = ((y + ky) * width + (x + kx)) * 4;
sum += data[idx + c] * kernel[ki];
ki++;
}
}
const idx = (y * width + x) * 4;
const original = data[idx + c];
resultData[idx + c] = Math.max(0, Math.min(255, original + (sum - original) * factor));
}
const idx = (y * width + x) * 4;
resultData[idx + 3] = data[idx + 3];
}
}
for (let x = 0; x < width; x++) {
for (let c = 0; c < 4; c++) {
resultData[x * 4 + c] = data[x * 4 + c];
resultData[((height - 1) * width + x) * 4 + c] = data[((height - 1) * width + x) * 4 + c];
}
}
for (let y = 0; y < height; y++) {
for (let c = 0; c < 4; c++) {
resultData[(y * width) * 4 + c] = data[(y * width) * 4 + c];
resultData[(y * width + width - 1) * 4 + c] = data[(y * width + width - 1) * 4 + c];
}
}
return new ImageData(resultData, width, height);
}

View file

@ -0,0 +1,65 @@
import { useEffect, useRef } from 'react';
import { useProjectStore } from '../stores/project-store';
const AUTO_SAVE_DELAY = 2000;
const STORAGE_KEY_PREFIX = 'openreel-image-project-';
export function useAutoSave() {
const { project, isDirty, markClean } = useProjectStore();
const lastSavedRef = useRef<string>('');
const timeoutRef = useRef<number>();
useEffect(() => {
if (!project || !isDirty) return;
const projectJson = JSON.stringify(project);
if (projectJson === lastSavedRef.current) return;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = window.setTimeout(() => {
try {
localStorage.setItem(`${STORAGE_KEY_PREFIX}${project.id}`, projectJson);
lastSavedRef.current = projectJson;
markClean();
} catch (error) {
console.error('Failed to auto-save:', error);
}
}, AUTO_SAVE_DELAY);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [project, isDirty, markClean]);
}
export function loadSavedProject(projectId: string) {
try {
const json = localStorage.getItem(`${STORAGE_KEY_PREFIX}${projectId}`);
if (json) {
return JSON.parse(json);
}
} catch (error) {
console.error('Failed to load saved project:', error);
}
return null;
}
export function getSavedProjectIds(): string[] {
const ids: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(STORAGE_KEY_PREFIX)) {
ids.push(key.replace(STORAGE_KEY_PREFIX, ''));
}
}
return ids;
}
export function deleteSavedProject(projectId: string): void {
localStorage.removeItem(`${STORAGE_KEY_PREFIX}${projectId}`);
}

View file

@ -0,0 +1,188 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 142.1 76.2% 36.3%;
--radius: 0.5rem;
--color-background-secondary: 250 250 250;
--color-background-tertiary: 245 245 245;
--color-background-elevated: 255 255 255;
--color-text-primary: 10 10 10;
--color-text-secondary: 115 115 115;
--color-text-muted: 163 163 163;
--color-border-hover: 212 212 212;
--color-border-active: 163 163 163;
}
.dark {
--background: 0 0% 7%;
--foreground: 0 0% 95%;
--card: 0 0% 9%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 95%;
--primary: 142.1 70.6% 45.3%;
--primary-foreground: 144.9 80.4% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 0 0% 15%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 50.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 20%;
--input: 240 3.7% 20%;
--ring: 142.1 70.6% 45.3%;
--color-background-secondary: 23 23 23;
--color-background-tertiary: 38 38 38;
--color-background-elevated: 32 32 32;
--color-text-primary: 250 250 250;
--color-text-secondary: 163 163 163;
--color-text-muted: 115 115 115;
--color-border-hover: 64 64 64;
--color-border-active: 82 82 82;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
html, body, #root {
height: 100%;
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
::selection {
background: hsl(var(--primary) / 0.3);
}
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
.canvas-container {
background-image:
linear-gradient(45deg, hsl(var(--muted)) 25%, transparent 25%),
linear-gradient(-45deg, hsl(var(--muted)) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, hsl(var(--muted)) 75%),
linear-gradient(-45deg, transparent 75%, hsl(var(--muted)) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
.layer-drag-ghost {
opacity: 0.8;
background: hsl(var(--primary) / 0.2);
border: 2px dashed hsl(var(--primary));
border-radius: var(--radius);
}
.resize-handle {
width: 10px;
height: 10px;
background: white;
border: 2px solid hsl(var(--primary));
border-radius: 2px;
position: absolute;
cursor: pointer;
}
.resize-handle-nw { top: -5px; left: -5px; cursor: nwse-resize; }
.resize-handle-n { top: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle-ne { top: -5px; right: -5px; cursor: nesw-resize; }
.resize-handle-e { top: 50%; right: -5px; transform: translateY(-50%); cursor: ew-resize; }
.resize-handle-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
.resize-handle-s { bottom: -5px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
.resize-handle-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.resize-handle-w { top: 50%; left: -5px; transform: translateY(-50%); cursor: ew-resize; }
.rotation-handle {
width: 12px;
height: 12px;
background: white;
border: 2px solid hsl(var(--primary));
border-radius: 50%;
position: absolute;
top: -30px;
left: 50%;
transform: translateX(-50%);
cursor: grab;
}
.rotation-handle:active {
cursor: grabbing;
}
@keyframes pulse-selection {
0%, 100% { box-shadow: 0 0 0 2px hsl(var(--primary)); }
50% { box-shadow: 0 0 0 4px hsl(var(--primary) / 0.5); }
}
.selection-box {
border: 2px solid hsl(var(--primary));
animation: pulse-selection 2s ease-in-out infinite;
}

View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,129 @@
import { removeBackground, Config } from '@imgly/background-removal';
export type BackgroundMode = 'transparent' | 'color' | 'blur';
export interface BackgroundRemovalOptions {
mode: BackgroundMode;
backgroundColor?: string;
blurAmount?: number;
}
export const DEFAULT_OPTIONS: BackgroundRemovalOptions = {
mode: 'transparent',
backgroundColor: '#ffffff',
blurAmount: 15,
};
export class BackgroundRemovalService {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
constructor() {
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d', { willReadFrequently: true })!;
}
async removeBackground(
imageSource: HTMLImageElement | ImageBitmap | string,
options: Partial<BackgroundRemovalOptions> = {},
onProgress?: (progress: number) => void
): Promise<string> {
const opts = { ...DEFAULT_OPTIONS, ...options };
onProgress?.(5);
let imageBlob: Blob;
if (typeof imageSource === 'string') {
if (imageSource.startsWith('data:')) {
const response = await fetch(imageSource);
imageBlob = await response.blob();
} else {
const response = await fetch(imageSource);
imageBlob = await response.blob();
}
} else {
this.canvas.width = imageSource.width;
this.canvas.height = imageSource.height;
this.ctx.drawImage(imageSource, 0, 0);
imageBlob = await new Promise<Blob>((resolve) => {
this.canvas.toBlob((blob) => resolve(blob!), 'image/png');
});
}
onProgress?.(10);
const config: Config = {
progress: (_key, current, total) => {
const baseProgress = 10;
const progressRange = 80;
const segmentProgress = (current / total) * progressRange;
onProgress?.(Math.round(baseProgress + segmentProgress));
},
output: {
format: 'image/png',
quality: 1,
},
};
const resultBlob = await removeBackground(imageBlob, config);
onProgress?.(90);
if (opts.mode === 'transparent') {
const dataUrl = await this.blobToDataUrl(resultBlob);
onProgress?.(100);
return dataUrl;
}
const maskedImg = await this.loadImageFromBlob(resultBlob);
const originalImg = await this.loadImageFromBlob(imageBlob);
this.canvas.width = originalImg.width;
this.canvas.height = originalImg.height;
if (opts.mode === 'color' && opts.backgroundColor) {
this.ctx.fillStyle = opts.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(maskedImg, 0, 0);
} else if (opts.mode === 'blur' && opts.blurAmount) {
this.ctx.filter = `blur(${opts.blurAmount}px)`;
this.ctx.drawImage(originalImg, 0, 0);
this.ctx.filter = 'none';
this.ctx.drawImage(maskedImg, 0, 0);
}
onProgress?.(100);
return this.canvas.toDataURL('image/png');
}
private async loadImageFromBlob(blob: Blob): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(img.src);
resolve(img);
};
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
private async blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
}
let serviceInstance: BackgroundRemovalService | null = null;
export function getBackgroundRemovalService(): BackgroundRemovalService {
if (!serviceInstance) {
serviceInstance = new BackgroundRemovalService();
}
return serviceInstance;
}

View file

@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { exportProject, exportArtboard, type ExportOptions } from './export-service';
import type { Project, Artboard } from '../types/project';
// ── Canvas mock ──────────────────────────────────────────────────────────────
//
// jsdom does not implement 2D canvas rendering, so we wire up a minimal mock
// that records calls and satisfies the toBlob contract.
function makeMockCanvas() {
const ctx = {
save: vi.fn(),
restore: vi.fn(),
scale: vi.fn(),
translate: vi.fn(),
rotate: vi.fn(),
transform: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
clip: vi.fn(),
drawImage: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn().mockReturnValue({ width: 100 }),
getImageData: vi.fn().mockReturnValue({
data: new Uint8ClampedArray(4),
width: 1,
height: 1,
}),
putImageData: vi.fn(),
createLinearGradient: vi.fn().mockReturnValue({
addColorStop: vi.fn(),
}),
createRadialGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
createImageData: vi.fn().mockReturnValue({ data: new Uint8ClampedArray(4) }),
globalAlpha: 1,
globalCompositeOperation: 'source-over',
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
shadowColor: '',
shadowBlur: 0,
shadowOffsetX: 0,
shadowOffsetY: 0,
filter: '',
font: '',
textAlign: 'left',
textBaseline: 'alphabetic',
lineCap: 'butt',
lineJoin: 'miter',
setLineDash: vi.fn(),
};
let blobCallback: ((blob: Blob | null) => void) | null = null;
const canvas = {
width: 0,
height: 0,
getContext: vi.fn().mockReturnValue(ctx),
toBlob: vi.fn().mockImplementation(
(callback: (blob: Blob | null) => void, _type?: string, _quality?: number) => {
blobCallback = callback;
// Resolve asynchronously to simulate browser behaviour.
setTimeout(() => callback(new Blob(['pixel-data'], { type: _type ?? 'image/png' })), 0);
},
),
toDataURL: vi.fn().mockReturnValue('data:image/png;base64,abc'),
_ctx: ctx,
_triggerBlob: () => blobCallback?.(new Blob(['pixel-data'])),
};
return canvas;
}
// ── Fixtures ──────────────────────────────────────────────────────────────────
function makeArtboard(id = 'ab1'): Artboard {
return {
id,
name: 'Artboard 1',
size: { width: 400, height: 300 },
background: { type: 'color', color: '#ffffff' },
layerIds: [],
position: { x: 0, y: 0 },
};
}
function makeProject(artboards?: Artboard[]): Project {
const ab = artboards ?? [makeArtboard('ab1')];
return {
id: 'proj1',
name: 'Test Project',
version: 1,
createdAt: Date.now(),
updatedAt: Date.now(),
artboards: ab,
layers: {},
assets: {},
exportPresets: [],
activeArtboardId: ab[0].id,
};
}
function makeOptions(overrides: Partial<ExportOptions> = {}): ExportOptions {
return {
format: 'png',
quality: 'high',
scale: 1,
background: 'include',
...overrides,
};
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('export-service', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createElementSpy: any;
beforeEach(() => {
// Intercept canvas creation and substitute the mock.
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') {
return makeMockCanvas() as unknown as HTMLCanvasElement;
}
// Fall through for other tags (e.g. img).
return document.createElement.call(document, tag);
});
});
afterEach(() => {
createElementSpy.mockRestore();
});
// ── exportArtboard ──────────────────────────────────────────────────────
describe('exportArtboard', () => {
it('returns a Blob for PNG format', async () => {
const project = makeProject();
const blob = await exportArtboard(project, project.artboards[0], makeOptions({ format: 'png' }));
expect(blob).toBeInstanceOf(Blob);
});
it('returns a Blob for JPEG format', async () => {
const project = makeProject();
const blob = await exportArtboard(
project,
project.artboards[0],
makeOptions({ format: 'jpg' }),
);
expect(blob).toBeInstanceOf(Blob);
});
it('returns a Blob for WebP format', async () => {
const project = makeProject();
const blob = await exportArtboard(
project,
project.artboards[0],
makeOptions({ format: 'webp' }),
);
expect(blob).toBeInstanceOf(Blob);
});
it('creates a canvas with the artboard dimensions × scale', async () => {
const project = makeProject();
await exportArtboard(project, project.artboards[0], makeOptions({ scale: 2 }));
// The canvas created by exportArtboard should have been given the scaled dimensions.
const mockCanvas = (createElementSpy.mock.results[0].value as ReturnType<typeof makeMockCanvas>);
expect(mockCanvas.width).toBe(800); // 400 × 2
expect(mockCanvas.height).toBe(600); // 300 × 2
});
it('fills background colour for include mode', async () => {
const project = makeProject();
const artboard = project.artboards[0];
await exportArtboard(project, artboard, makeOptions({ background: 'include', format: 'png' }));
const mockCanvas = createElementSpy.mock.results[0].value as ReturnType<typeof makeMockCanvas>;
expect(mockCanvas._ctx.fillRect).toHaveBeenCalled();
});
it('does not fill background for transparent mode (PNG)', async () => {
const project = makeProject();
await exportArtboard(
project,
project.artboards[0],
makeOptions({ background: 'transparent', format: 'png' }),
);
const mockCanvas = createElementSpy.mock.results[0].value as ReturnType<typeof makeMockCanvas>;
expect(mockCanvas._ctx.fillRect).not.toHaveBeenCalled();
});
it('always fills background for JPEG (no transparency)', async () => {
const project = makeProject([
{ ...makeArtboard(), background: { type: 'transparent' } },
]);
await exportArtboard(
project,
project.artboards[0],
makeOptions({ background: 'transparent', format: 'jpg' }),
);
const mockCanvas = createElementSpy.mock.results[0].value as ReturnType<typeof makeMockCanvas>;
expect(mockCanvas._ctx.fillRect).toHaveBeenCalled();
});
});
// ── exportProject ───────────────────────────────────────────────────────
describe('exportProject', () => {
it('returns one blob per artboard', async () => {
const project = makeProject([makeArtboard('a1'), makeArtboard('a2')]);
const blobs = await exportProject(project, makeOptions());
expect(blobs).toHaveLength(2);
});
it('filters artboards when artboardIds is provided', async () => {
const project = makeProject([makeArtboard('a1'), makeArtboard('a2'), makeArtboard('a3')]);
const blobs = await exportProject(project, makeOptions({ artboardIds: ['a1', 'a3'] }));
expect(blobs).toHaveLength(2);
});
it('calls onProgress with 100% at the end', async () => {
const project = makeProject();
const onProgress = vi.fn();
await exportProject(project, makeOptions(), onProgress);
const lastCall = onProgress.mock.calls[onProgress.mock.calls.length - 1];
expect(lastCall[0]).toBe(100);
});
it('reports progress for each artboard', async () => {
const project = makeProject([makeArtboard('a1'), makeArtboard('a2')]);
const onProgress = vi.fn();
await exportProject(project, makeOptions(), onProgress);
// At minimum one intermediate progress call before the final 100.
expect(onProgress.mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
});

Some files were not shown because too many files have changed in this diff Show more