fix: remove openreel editor; fire df:assets-changed on upload/ingest complete
This commit is contained in:
parent
c36c732f47
commit
721f847b28
660 changed files with 5 additions and 216422 deletions
|
|
@ -123,15 +123,6 @@ services:
|
|||
networks:
|
||||
- wild-dragon
|
||||
|
||||
# editor:
|
||||
# build: ./services/editor
|
||||
# depends_on:
|
||||
# - mam-api
|
||||
# ports:
|
||||
# - "${PORT_EDITOR:-7435}:80"
|
||||
# networks:
|
||||
# - wild-dragon
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
|
|
|||
65
services/editor/.gitignore
vendored
65
services/editor/.gitignore
vendored
|
|
@ -1,65 +0,0 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.cache/
|
||||
.temp/
|
||||
.docs/
|
||||
docs/
|
||||
# Project-specific
|
||||
/public/projects/
|
||||
*.openreel
|
||||
apps/cloud/
|
||||
apps/ios
|
||||
apps/android
|
||||
|
||||
|
||||
|
||||
# Local files
|
||||
FEATURES_TWITTER.md
|
||||
.claude-tasks.md
|
||||
CLAUDE.md
|
||||
*-PLAN.md
|
||||
*-PLAN-*.md
|
||||
.playwright-mcp/
|
||||
.wrangler/
|
||||
|
||||
|
||||
mobile-mockup/
|
||||
2
services/editor/.serena/.gitignore
vendored
2
services/editor/.serena/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
|||
/cache
|
||||
/project.local.yml
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
# the name by which the project can be referenced within Serena
|
||||
project_name: "openreel-video"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
|
@ -1,387 +0,0 @@
|
|||
# Contributing to OpenReel
|
||||
|
||||
Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Table of Contents
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Standards](#coding-standards)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Testing](#testing)
|
||||
- [Submitting Changes](#submitting-changes)
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Be respectful, constructive, and professional. We're building something great together!
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18 or higher
|
||||
- pnpm (recommended) or npm
|
||||
- Git
|
||||
- Modern browser with WebCodecs support (Chrome 94+, Edge 94+)
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
# 1. Fork and clone the repository
|
||||
git clone https://github.com/Augani/openreel-video.git
|
||||
cd openreel-video
|
||||
|
||||
# 2. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 3. Start development server
|
||||
pnpm dev
|
||||
|
||||
# 4. Open browser to http://localhost:5173
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
openreel/
|
||||
├── apps/
|
||||
│ └── web/ # Main web application
|
||||
│ ├── public/ # Static assets
|
||||
│ └── src/
|
||||
│ ├── components/ # React components
|
||||
│ ├── stores/ # State management (Zustand)
|
||||
│ ├── bridges/ # Core engine bridges
|
||||
│ └── services/ # Business logic
|
||||
├── packages/
|
||||
│ └── core/ # Shared core logic
|
||||
│ ├── src/
|
||||
│ │ ├── actions/ # Action system
|
||||
│ │ ├── video/ # Video processing
|
||||
│ │ ├── audio/ # Audio processing
|
||||
│ │ ├── graphics/ # Graphics & SVG
|
||||
│ │ ├── text/ # Text & titles
|
||||
│ │ └── export/ # Export engine
|
||||
│ └── types/ # TypeScript types
|
||||
```
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- **Strict mode**: Always use TypeScript strict mode
|
||||
- **Types**: Prefer interfaces over types for object shapes
|
||||
- **No `any`**: Avoid `any` - use `unknown` or proper types
|
||||
- **Naming**:
|
||||
- Components: `PascalCase` (e.g., `Timeline`, `Preview`)
|
||||
- Functions: `camelCase` (e.g., `handleClick`, `processVideo`)
|
||||
- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`)
|
||||
- Files: `kebab-case.tsx` or `PascalCase.tsx` for components
|
||||
|
||||
### Code Style
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface VideoClip {
|
||||
id: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
function processClip(clip: VideoClip): ProcessedClip {
|
||||
if (!clip.id) {
|
||||
throw new Error('Clip ID is required');
|
||||
}
|
||||
|
||||
return {
|
||||
...clip,
|
||||
processed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ❌ Avoid
|
||||
function processClip(clip: any) {
|
||||
console.log('Processing...'); // Remove debug logs
|
||||
const result = clip; // Unclear what's happening
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### React Components
|
||||
|
||||
```typescript
|
||||
// ✅ Good
|
||||
interface TimelineProps {
|
||||
tracks: Track[];
|
||||
onClipSelect: (clipId: string) => void;
|
||||
}
|
||||
|
||||
export const Timeline: React.FC<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! 🎬
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# syntax=docker/dockerfile:1.6
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache python3 make g++ git bash
|
||||
RUN corepack enable && corepack prepare pnpm@9.7.0 --activate
|
||||
WORKDIR /build
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json mediabunny.d.ts ./
|
||||
COPY apps ./apps
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile=false
|
||||
RUN pnpm build:wasm || echo "no wasm build step, continuing"
|
||||
RUN pnpm --filter @openreel/web build
|
||||
RUN ls -la apps/web/dist
|
||||
|
||||
FROM nginx:alpine AS runtime
|
||||
RUN rm -rf /usr/share/nginx/html/* /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /build/apps/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# Z-AMPP <-> openreel-video integration
|
||||
|
||||
Vendored from https://github.com/Augani/openreel-video (MIT). The upstream .git directory was removed so this lives as plain source we can patch freely.
|
||||
|
||||
## Files added (Z-AMPP-only, not upstream)
|
||||
- Dockerfile, nginx.conf, VENDOR.txt, INTEGRATION.md
|
||||
- apps/web/src/mam-bridge.ts: boot hook + pickFromMAM() modal
|
||||
- packages/core/src/export/mam-export-target.ts: helpers for upload-to-MAM
|
||||
|
||||
## Upstream files patched
|
||||
- apps/web/package.json: build script changed `tsc --noEmit && vite build` -> `vite build`. Original preserved as build:strict. (upstream tsc fails on pre-existing WebGPU + import.meta errors.)
|
||||
- apps/web/src/bridges/media-bridge.ts: appended importFromURL(url, name, contentType?) as the last method of the MediaBridge class.
|
||||
- apps/web/src/main.tsx: appended `import "./mam-bridge";` so the bridge boot hook runs.
|
||||
|
||||
## Query params honored
|
||||
- ?asset=<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.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 Augustus Otu and Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
# OpenReel Video
|
||||
|
||||
> **The open source CapCut alternative. Professional video editing in your browser. No uploads. No installs. 100% open source.**
|
||||
|
||||
OpenReel Video is a fully-featured browser-based video editor that runs entirely client-side. Built with React, TypeScript, WebCodecs, and WebGPU for professional-grade video editing without the need for expensive software or cloud processing.
|
||||
|
||||
**[Try it Live](https://openreel.video)** | **[Documentation](CONTRIBUTING.md)** | **[Discussions](https://github.com/Augani/openreel-video/discussions)** | **[Twitter](https://x.com/python_xi)**
|
||||
|
||||
   
|
||||
|
||||
---
|
||||
|
||||
## 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.*
|
||||
|
|
@ -1 +0,0 @@
|
|||
Vendored from Augani/openreel-video @ 2026-05-18T01:29:08Z
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import js from "@eslint/js";
|
||||
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||
import tsparser from "@typescript-eslint/parser";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
parser: tsparser,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
...globals.node,
|
||||
NodeJS: "readonly",
|
||||
CanvasTextAlign: "readonly",
|
||||
CanvasTextBaseline: "readonly",
|
||||
CanvasLineCap: "readonly",
|
||||
CanvasLineJoin: "readonly",
|
||||
CanvasFillRule: "readonly",
|
||||
GlobalCompositeOperation: "readonly",
|
||||
ImageBitmap: "readonly",
|
||||
OffscreenCanvas: "readonly",
|
||||
OffscreenCanvasRenderingContext2D: "readonly",
|
||||
React: "readonly",
|
||||
JSX: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tseslint,
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...tseslint.configs.recommended.rules,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
"prefer-const": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"no-empty": "warn",
|
||||
"no-case-declarations": "warn",
|
||||
"react-hooks/rules-of-hooks": "warn",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
},
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
"*.config.js",
|
||||
"*.config.ts",
|
||||
"vite.config.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{
|
||||
"name": "@openreel/image",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "wrangler pages deploy dist --project-name=openreel-image",
|
||||
"deploy:preview": "wrangler pages deploy dist --project-name=openreel-image --branch=preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist node_modules/.vite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"@openreel/image-core": "workspace:*",
|
||||
"@openreel/ui": "workspace:*",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.555.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.53.0",
|
||||
"@typescript-eslint/parser": "^8.53.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"globals": "^17.0.0",
|
||||
"jsdom": "^24.1.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0",
|
||||
"wrangler": "^3.114.17"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 389 B |
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"name": "OpenReel Image",
|
||||
"short_name": "OpenReel",
|
||||
"description": "Professional browser-based graphic design editor",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#22c55e",
|
||||
"orientation": "landscape",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["graphics", "design", "productivity"]
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
const CACHE_NAME = 'openreel-image-v1';
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(
|
||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
||||
)
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
if (url.origin !== location.origin) return;
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request).then((cached) => {
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.status === 200) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
|
||||
return cached || fetchPromise;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useUIStore } from './stores/ui-store';
|
||||
import { WelcomeScreen } from './components/welcome/WelcomeScreen';
|
||||
import { EditorInterface } from './components/editor/EditorInterface';
|
||||
import { KeyboardShortcutsPanel } from './components/editor/KeyboardShortcutsPanel';
|
||||
import { SettingsDialog } from './components/editor/SettingsDialog';
|
||||
import { useKeyboardShortcuts } from './services/keyboard-service';
|
||||
import { useAutoSave } from './hooks/useAutoSave';
|
||||
|
||||
export default function App() {
|
||||
const { currentView, setCurrentView, showShortcutsPanel, toggleShortcutsPanel, showSettingsDialog, closeSettingsDialog } = useUIStore();
|
||||
|
||||
useKeyboardShortcuts();
|
||||
useAutoSave();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.add('dark');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && currentView === 'editor') {
|
||||
setCurrentView('welcome');
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [currentView, setCurrentView]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
export interface BlackWhiteSettings {
|
||||
reds: number;
|
||||
yellows: number;
|
||||
greens: number;
|
||||
cyans: number;
|
||||
blues: number;
|
||||
magentas: number;
|
||||
tint: {
|
||||
enabled: boolean;
|
||||
hue: number;
|
||||
saturation: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_BLACK_WHITE: BlackWhiteSettings = {
|
||||
reds: 40,
|
||||
yellows: 60,
|
||||
greens: 40,
|
||||
cyans: 60,
|
||||
blues: 20,
|
||||
magentas: 80,
|
||||
tint: {
|
||||
enabled: false,
|
||||
hue: 30,
|
||||
saturation: 25,
|
||||
},
|
||||
};
|
||||
|
||||
export const BLACK_WHITE_PRESETS = {
|
||||
default: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
|
||||
highContrast: { reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20, magentas: 80 },
|
||||
infrared: { reds: -70, yellows: 200, greens: -70, cyans: 200, blues: -20, magentas: -20 },
|
||||
maximumWhite: { reds: 100, yellows: 100, greens: 100, cyans: 100, blues: 100, magentas: 100 },
|
||||
maximumBlack: { reds: -200, yellows: -200, greens: -200, cyans: -200, blues: -200, magentas: -200 },
|
||||
neutral: { reds: 33, yellows: 33, greens: 33, cyans: 33, blues: 33, magentas: 33 },
|
||||
redFilter: { reds: 106, yellows: 52, greens: -10, cyans: -40, blues: -30, magentas: 94 },
|
||||
yellowFilter: { reds: 34, yellows: 106, greens: 54, cyans: -26, blues: -50, magentas: 14 },
|
||||
greenFilter: { reds: -44, yellows: 64, greens: 106, cyans: 60, blues: -30, magentas: -70 },
|
||||
blueFilter: { reds: -30, yellows: -46, greens: -16, cyans: 30, blues: 106, magentas: 30 },
|
||||
};
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
let h: number;
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
if (s === 0) {
|
||||
const gray = Math.round(l * 255);
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
|
||||
const hue2rgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
return {
|
||||
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
||||
g: Math.round(hue2rgb(p, q, h) * 255),
|
||||
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
||||
};
|
||||
}
|
||||
|
||||
function getColorWeight(hue: number, targetHue: number, spread: number = 60): number {
|
||||
let diff = Math.abs(hue - targetHue);
|
||||
if (diff > 180) diff = 360 - diff;
|
||||
if (diff >= spread) return 0;
|
||||
return 1 - diff / spread;
|
||||
}
|
||||
|
||||
export function applyBlackWhite(imageData: ImageData, settings: BlackWhiteSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const { h, s } = rgbToHsl(r, g, b);
|
||||
const hue = h * 360;
|
||||
|
||||
let gray = (r + g + b) / 3;
|
||||
|
||||
if (s > 0.05) {
|
||||
const redWeight = getColorWeight(hue, 0) + getColorWeight(hue, 360);
|
||||
const yellowWeight = getColorWeight(hue, 60);
|
||||
const greenWeight = getColorWeight(hue, 120);
|
||||
const cyanWeight = getColorWeight(hue, 180);
|
||||
const blueWeight = getColorWeight(hue, 240);
|
||||
const magentaWeight = getColorWeight(hue, 300);
|
||||
|
||||
const totalWeight = redWeight + yellowWeight + greenWeight + cyanWeight + blueWeight + magentaWeight;
|
||||
|
||||
if (totalWeight > 0) {
|
||||
const adjustment =
|
||||
(redWeight * settings.reds +
|
||||
yellowWeight * settings.yellows +
|
||||
greenWeight * settings.greens +
|
||||
cyanWeight * settings.cyans +
|
||||
blueWeight * settings.blues +
|
||||
magentaWeight * settings.magentas) / totalWeight;
|
||||
|
||||
gray = gray * (1 + (adjustment - 50) / 100 * s);
|
||||
}
|
||||
}
|
||||
|
||||
gray = Math.max(0, Math.min(255, gray));
|
||||
|
||||
let finalR = gray;
|
||||
let finalG = gray;
|
||||
let finalB = gray;
|
||||
|
||||
if (settings.tint.enabled) {
|
||||
const tintH = settings.tint.hue / 360;
|
||||
const tintS = settings.tint.saturation / 100;
|
||||
const tintL = gray / 255;
|
||||
|
||||
const tinted = hslToRgb(tintH, tintS, tintL);
|
||||
finalR = tinted.r;
|
||||
finalG = tinted.g;
|
||||
finalB = tinted.b;
|
||||
}
|
||||
|
||||
resultData[i] = finalR;
|
||||
resultData[i + 1] = finalG;
|
||||
resultData[i + 2] = finalB;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
export interface ChannelMixerSettings {
|
||||
red: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
green: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
blue: {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
constant: number;
|
||||
};
|
||||
monochrome: boolean;
|
||||
monoRed: number;
|
||||
monoGreen: number;
|
||||
monoBlue: number;
|
||||
monoConstant: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CHANNEL_MIXER: ChannelMixerSettings = {
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
monochrome: false,
|
||||
monoRed: 40,
|
||||
monoGreen: 40,
|
||||
monoBlue: 20,
|
||||
monoConstant: 0,
|
||||
};
|
||||
|
||||
export const CHANNEL_MIXER_PRESETS = {
|
||||
default: {
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
swapRedBlue: {
|
||||
red: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
},
|
||||
sepia: {
|
||||
red: { red: 100, green: 50, blue: 0, constant: 0 },
|
||||
green: { red: 60, green: 60, blue: 0, constant: 0 },
|
||||
blue: { red: 30, green: 30, blue: 30, constant: 0 },
|
||||
},
|
||||
cyberPunk: {
|
||||
red: { red: 100, green: 0, blue: 50, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 50, constant: 0 },
|
||||
blue: { red: 50, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
export function applyChannelMixer(imageData: ImageData, settings: ChannelMixerSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let newR: number, newG: number, newB: number;
|
||||
|
||||
if (settings.monochrome) {
|
||||
const gray =
|
||||
r * (settings.monoRed / 100) +
|
||||
g * (settings.monoGreen / 100) +
|
||||
b * (settings.monoBlue / 100) +
|
||||
settings.monoConstant * 2.55;
|
||||
|
||||
newR = newG = newB = Math.max(0, Math.min(255, gray));
|
||||
} else {
|
||||
newR =
|
||||
r * (settings.red.red / 100) +
|
||||
g * (settings.red.green / 100) +
|
||||
b * (settings.red.blue / 100) +
|
||||
settings.red.constant * 2.55;
|
||||
|
||||
newG =
|
||||
r * (settings.green.red / 100) +
|
||||
g * (settings.green.green / 100) +
|
||||
b * (settings.green.blue / 100) +
|
||||
settings.green.constant * 2.55;
|
||||
|
||||
newB =
|
||||
r * (settings.blue.red / 100) +
|
||||
g * (settings.blue.green / 100) +
|
||||
b * (settings.blue.blue / 100) +
|
||||
settings.blue.constant * 2.55;
|
||||
}
|
||||
|
||||
resultData[i] = Math.max(0, Math.min(255, newR));
|
||||
resultData[i + 1] = Math.max(0, Math.min(255, newG));
|
||||
resultData[i + 2] = Math.max(0, Math.min(255, newB));
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
export interface ColorBalanceSettings {
|
||||
shadows: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
midtones: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
highlights: {
|
||||
cyanRed: number;
|
||||
magentaGreen: number;
|
||||
yellowBlue: number;
|
||||
};
|
||||
preserveLuminosity: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_BALANCE: ColorBalanceSettings = {
|
||||
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
preserveLuminosity: true,
|
||||
};
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
return r * 0.299 + g * 0.587 + b * 0.114;
|
||||
}
|
||||
|
||||
function getToneWeight(luminance: number, tone: 'shadows' | 'midtones' | 'highlights'): number {
|
||||
const normalized = luminance / 255;
|
||||
|
||||
switch (tone) {
|
||||
case 'shadows':
|
||||
if (normalized <= 0.25) return 1;
|
||||
if (normalized <= 0.5) return 1 - (normalized - 0.25) / 0.25;
|
||||
return 0;
|
||||
|
||||
case 'highlights':
|
||||
if (normalized >= 0.75) return 1;
|
||||
if (normalized >= 0.5) return (normalized - 0.5) / 0.25;
|
||||
return 0;
|
||||
|
||||
case 'midtones':
|
||||
if (normalized >= 0.25 && normalized <= 0.75) {
|
||||
const distFromCenter = Math.abs(normalized - 0.5);
|
||||
return 1 - distFromCenter / 0.25;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyColorBalance(imageData: ImageData, settings: ColorBalanceSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
let r = data[i];
|
||||
let g = data[i + 1];
|
||||
let b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const luminance = getLuminance(r, g, b);
|
||||
|
||||
const shadowWeight = getToneWeight(luminance, 'shadows');
|
||||
const midtoneWeight = getToneWeight(luminance, 'midtones');
|
||||
const highlightWeight = getToneWeight(luminance, 'highlights');
|
||||
|
||||
let rShift = 0, gShift = 0, bShift = 0;
|
||||
|
||||
if (shadowWeight > 0) {
|
||||
rShift += settings.shadows.cyanRed * shadowWeight;
|
||||
gShift += settings.shadows.magentaGreen * shadowWeight;
|
||||
bShift += settings.shadows.yellowBlue * shadowWeight;
|
||||
}
|
||||
|
||||
if (midtoneWeight > 0) {
|
||||
rShift += settings.midtones.cyanRed * midtoneWeight;
|
||||
gShift += settings.midtones.magentaGreen * midtoneWeight;
|
||||
bShift += settings.midtones.yellowBlue * midtoneWeight;
|
||||
}
|
||||
|
||||
if (highlightWeight > 0) {
|
||||
rShift += settings.highlights.cyanRed * highlightWeight;
|
||||
gShift += settings.highlights.magentaGreen * highlightWeight;
|
||||
bShift += settings.highlights.yellowBlue * highlightWeight;
|
||||
}
|
||||
|
||||
r = Math.max(0, Math.min(255, r + rShift));
|
||||
g = Math.max(0, Math.min(255, g + gShift));
|
||||
b = Math.max(0, Math.min(255, b + bShift));
|
||||
|
||||
if (settings.preserveLuminosity) {
|
||||
const newLuminance = getLuminance(r, g, b);
|
||||
if (newLuminance > 0) {
|
||||
const ratio = luminance / newLuminance;
|
||||
r = Math.max(0, Math.min(255, r * ratio));
|
||||
g = Math.max(0, Math.min(255, g * ratio));
|
||||
b = Math.max(0, Math.min(255, b * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
resultData[i] = r;
|
||||
resultData[i + 1] = g;
|
||||
resultData[i + 2] = b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
export interface ColorLookupSettings {
|
||||
lutData: Float32Array | null;
|
||||
lutSize: number;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR_LOOKUP: ColorLookupSettings = {
|
||||
lutData: null,
|
||||
lutSize: 0,
|
||||
strength: 100,
|
||||
};
|
||||
|
||||
export function parseCubeLUT(content: string): { data: Float32Array; size: number } | null {
|
||||
const lines = content.split('\n');
|
||||
let size = 0;
|
||||
const data: number[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('#') || trimmed === '') continue;
|
||||
|
||||
if (trimmed.startsWith('LUT_3D_SIZE')) {
|
||||
const match = trimmed.match(/LUT_3D_SIZE\s+(\d+)/);
|
||||
if (match) {
|
||||
size = parseInt(match[1], 10);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('TITLE') || trimmed.startsWith('DOMAIN_')) continue;
|
||||
|
||||
const values = trimmed.split(/\s+/).map(parseFloat);
|
||||
if (values.length === 3 && values.every((v) => !isNaN(v))) {
|
||||
data.push(...values);
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 0 || data.length !== size * size * size * 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { data: new Float32Array(data), size };
|
||||
}
|
||||
|
||||
export function parse3dlLUT(content: string): { data: Float32Array; size: number } | null {
|
||||
const lines = content.split('\n');
|
||||
const data: number[] = [];
|
||||
let size = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '' || trimmed.startsWith('#')) continue;
|
||||
|
||||
const values = trimmed.split(/\s+/).map(parseFloat);
|
||||
|
||||
if (values.length === 1 && size === 0) {
|
||||
size = Math.round(Math.cbrt(values[0]));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (values.length === 3 && values.every((v) => !isNaN(v))) {
|
||||
data.push(values[0] / 4095, values[1] / 4095, values[2] / 4095);
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 0) {
|
||||
size = Math.round(Math.cbrt(data.length / 3));
|
||||
}
|
||||
|
||||
if (size === 0 || data.length !== size * size * size * 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { data: new Float32Array(data), size };
|
||||
}
|
||||
|
||||
function trilinearInterpolate(
|
||||
lutData: Float32Array,
|
||||
size: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number
|
||||
): { r: number; g: number; b: number } {
|
||||
const rScaled = r * (size - 1);
|
||||
const gScaled = g * (size - 1);
|
||||
const bScaled = b * (size - 1);
|
||||
|
||||
const r0 = Math.floor(rScaled);
|
||||
const g0 = Math.floor(gScaled);
|
||||
const b0 = Math.floor(bScaled);
|
||||
|
||||
const r1 = Math.min(r0 + 1, size - 1);
|
||||
const g1 = Math.min(g0 + 1, size - 1);
|
||||
const b1 = Math.min(b0 + 1, size - 1);
|
||||
|
||||
const rFrac = rScaled - r0;
|
||||
const gFrac = gScaled - g0;
|
||||
const bFrac = bScaled - b0;
|
||||
|
||||
const getIndex = (ri: number, gi: number, bi: number) => (bi * size * size + gi * size + ri) * 3;
|
||||
|
||||
const c000 = getIndex(r0, g0, b0);
|
||||
const c100 = getIndex(r1, g0, b0);
|
||||
const c010 = getIndex(r0, g1, b0);
|
||||
const c110 = getIndex(r1, g1, b0);
|
||||
const c001 = getIndex(r0, g0, b1);
|
||||
const c101 = getIndex(r1, g0, b1);
|
||||
const c011 = getIndex(r0, g1, b1);
|
||||
const c111 = getIndex(r1, g1, b1);
|
||||
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
const interpolate = (channel: number) => {
|
||||
const c00 = lerp(lutData[c000 + channel], lutData[c100 + channel], rFrac);
|
||||
const c01 = lerp(lutData[c001 + channel], lutData[c101 + channel], rFrac);
|
||||
const c10 = lerp(lutData[c010 + channel], lutData[c110 + channel], rFrac);
|
||||
const c11 = lerp(lutData[c011 + channel], lutData[c111 + channel], rFrac);
|
||||
|
||||
const c0 = lerp(c00, c10, gFrac);
|
||||
const c1 = lerp(c01, c11, gFrac);
|
||||
|
||||
return lerp(c0, c1, bFrac);
|
||||
};
|
||||
|
||||
return {
|
||||
r: interpolate(0),
|
||||
g: interpolate(1),
|
||||
b: interpolate(2),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyColorLookup(imageData: ImageData, settings: ColorLookupSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
if (!settings.lutData || settings.lutSize === 0) {
|
||||
resultData.set(data);
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
const strength = settings.strength / 100;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i] / 255;
|
||||
const g = data[i + 1] / 255;
|
||||
const b = data[i + 2] / 255;
|
||||
const a = data[i + 3];
|
||||
|
||||
const lutColor = trilinearInterpolate(settings.lutData, settings.lutSize, r, g, b);
|
||||
|
||||
resultData[i] = Math.max(0, Math.min(255, (r + (lutColor.r - r) * strength) * 255));
|
||||
resultData[i + 1] = Math.max(0, Math.min(255, (g + (lutColor.g - g) * strength) * 255));
|
||||
resultData[i + 2] = Math.max(0, Math.min(255, (b + (lutColor.b - b) * strength) * 255));
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function createIdentityLUT(size: number): Float32Array {
|
||||
const data = new Float32Array(size * size * size * 3);
|
||||
|
||||
for (let b = 0; b < size; b++) {
|
||||
for (let g = 0; g < size; g++) {
|
||||
for (let r = 0; r < size; r++) {
|
||||
const idx = (b * size * size + g * size + r) * 3;
|
||||
data[idx] = r / (size - 1);
|
||||
data[idx + 1] = g / (size - 1);
|
||||
data[idx + 2] = b / (size - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
export interface GradientStop {
|
||||
position: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface GradientMapSettings {
|
||||
stops: GradientStop[];
|
||||
dither: boolean;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRADIENT_MAP: GradientMapSettings = {
|
||||
stops: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
dither: false,
|
||||
reverse: false,
|
||||
};
|
||||
|
||||
export const GRADIENT_MAP_PRESETS = {
|
||||
blackWhite: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
sepiaTone: [
|
||||
{ position: 0, color: '#1a0f00' },
|
||||
{ position: 50, color: '#8b6914' },
|
||||
{ position: 100, color: '#ffe7b3' },
|
||||
],
|
||||
duotoneBlueOrange: [
|
||||
{ position: 0, color: '#001f4d' },
|
||||
{ position: 100, color: '#ff8c00' },
|
||||
],
|
||||
duotonePurpleTeal: [
|
||||
{ position: 0, color: '#2d1b4e' },
|
||||
{ position: 100, color: '#00d4aa' },
|
||||
],
|
||||
sunset: [
|
||||
{ position: 0, color: '#1a0533' },
|
||||
{ position: 33, color: '#6b1839' },
|
||||
{ position: 66, color: '#d44d1b' },
|
||||
{ position: 100, color: '#ffd700' },
|
||||
],
|
||||
coolBlue: [
|
||||
{ position: 0, color: '#000033' },
|
||||
{ position: 50, color: '#0066cc' },
|
||||
{ position: 100, color: '#99ccff' },
|
||||
],
|
||||
warmRed: [
|
||||
{ position: 0, color: '#1a0000' },
|
||||
{ position: 50, color: '#cc3300' },
|
||||
{ position: 100, color: '#ffcc99' },
|
||||
],
|
||||
greenForest: [
|
||||
{ position: 0, color: '#001a00' },
|
||||
{ position: 50, color: '#336600' },
|
||||
{ position: 100, color: '#99cc66' },
|
||||
],
|
||||
infrared: [
|
||||
{ position: 0, color: '#000000' },
|
||||
{ position: 25, color: '#330066' },
|
||||
{ position: 50, color: '#ff0066' },
|
||||
{ position: 75, color: '#ffcc00' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
thermal: [
|
||||
{ position: 0, color: '#000033' },
|
||||
{ position: 25, color: '#6600cc' },
|
||||
{ position: 50, color: '#ff0000' },
|
||||
{ position: 75, color: '#ffff00' },
|
||||
{ position: 100, color: '#ffffff' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseColor(color: string): { r: number; g: number; b: number } {
|
||||
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
|
||||
if (match) {
|
||||
return {
|
||||
r: parseInt(match[1], 16),
|
||||
g: parseInt(match[2], 16),
|
||||
b: parseInt(match[3], 16),
|
||||
};
|
||||
}
|
||||
return { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
function interpolateGradient(
|
||||
stops: GradientStop[],
|
||||
position: number
|
||||
): { r: number; g: number; b: number } {
|
||||
if (stops.length === 0) return { r: 0, g: 0, b: 0 };
|
||||
if (stops.length === 1) return parseColor(stops[0].color);
|
||||
|
||||
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||
|
||||
if (position <= sortedStops[0].position) {
|
||||
return parseColor(sortedStops[0].color);
|
||||
}
|
||||
if (position >= sortedStops[sortedStops.length - 1].position) {
|
||||
return parseColor(sortedStops[sortedStops.length - 1].color);
|
||||
}
|
||||
|
||||
for (let i = 0; i < sortedStops.length - 1; i++) {
|
||||
const stop1 = sortedStops[i];
|
||||
const stop2 = sortedStops[i + 1];
|
||||
|
||||
if (position >= stop1.position && position <= stop2.position) {
|
||||
const t = (position - stop1.position) / (stop2.position - stop1.position);
|
||||
const c1 = parseColor(stop1.color);
|
||||
const c2 = parseColor(stop2.color);
|
||||
|
||||
return {
|
||||
r: Math.round(c1.r + (c2.r - c1.r) * t),
|
||||
g: Math.round(c1.g + (c2.g - c1.g) * t),
|
||||
b: Math.round(c1.b + (c2.b - c1.b) * t),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return parseColor(sortedStops[sortedStops.length - 1].color);
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number): number {
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) / 255;
|
||||
}
|
||||
|
||||
export function applyGradientMap(imageData: ImageData, settings: GradientMapSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const lookupTable: Array<{ r: number; g: number; b: number }> = [];
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let position = (i / 255) * 100;
|
||||
if (settings.reverse) {
|
||||
position = 100 - position;
|
||||
}
|
||||
lookupTable[i] = interpolateGradient(settings.stops, position);
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let luminance = getLuminance(r, g, b);
|
||||
|
||||
if (settings.dither) {
|
||||
const noise = (Math.random() - 0.5) * (1 / 255);
|
||||
luminance = Math.max(0, Math.min(1, luminance + noise));
|
||||
}
|
||||
|
||||
const idx = Math.round(luminance * 255);
|
||||
const mappedColor = lookupTable[idx];
|
||||
|
||||
resultData[i] = mappedColor.r;
|
||||
resultData[i + 1] = mappedColor.g;
|
||||
resultData[i + 2] = mappedColor.b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,305 +0,0 @@
|
|||
export interface HistogramData {
|
||||
red: Uint32Array;
|
||||
green: Uint32Array;
|
||||
blue: Uint32Array;
|
||||
luminosity: Uint32Array;
|
||||
}
|
||||
|
||||
export interface HistogramStatistics {
|
||||
mean: number;
|
||||
stdDev: number;
|
||||
median: number;
|
||||
min: number;
|
||||
max: number;
|
||||
pixelCount: number;
|
||||
shadowsClipped: number;
|
||||
highlightsClipped: number;
|
||||
}
|
||||
|
||||
export interface HistogramResult {
|
||||
data: HistogramData;
|
||||
statistics: {
|
||||
red: HistogramStatistics;
|
||||
green: HistogramStatistics;
|
||||
blue: HistogramStatistics;
|
||||
luminosity: HistogramStatistics;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ColorInfo {
|
||||
rgb: { r: number; g: number; b: number };
|
||||
hsb: { h: number; s: number; b: number };
|
||||
hsl: { h: number; s: number; l: number };
|
||||
lab: { l: number; a: number; b: number };
|
||||
cmyk: { c: number; m: number; y: number; k: number };
|
||||
hex: string;
|
||||
}
|
||||
|
||||
function calculateStatistics(histogram: Uint32Array, totalPixels: number): HistogramStatistics {
|
||||
let sum = 0;
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
let pixelCount = 0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const count = histogram[i];
|
||||
if (count > 0) {
|
||||
sum += i * count;
|
||||
pixelCount += count;
|
||||
if (i < min) min = i;
|
||||
if (i > max) max = i;
|
||||
}
|
||||
}
|
||||
|
||||
const mean = pixelCount > 0 ? sum / pixelCount : 0;
|
||||
|
||||
let varianceSum = 0;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
const count = histogram[i];
|
||||
if (count > 0) {
|
||||
varianceSum += count * Math.pow(i - mean, 2);
|
||||
}
|
||||
}
|
||||
const stdDev = pixelCount > 0 ? Math.sqrt(varianceSum / pixelCount) : 0;
|
||||
|
||||
let medianCount = 0;
|
||||
let median = 0;
|
||||
const halfCount = pixelCount / 2;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
medianCount += histogram[i];
|
||||
if (medianCount >= halfCount) {
|
||||
median = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const shadowsClipped = (histogram[0] / totalPixels) * 100;
|
||||
const highlightsClipped = (histogram[255] / totalPixels) * 100;
|
||||
|
||||
return {
|
||||
mean,
|
||||
stdDev,
|
||||
median,
|
||||
min: pixelCount > 0 ? min : 0,
|
||||
max: pixelCount > 0 ? max : 0,
|
||||
pixelCount,
|
||||
shadowsClipped,
|
||||
highlightsClipped,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateHistogram(imageData: ImageData): HistogramResult {
|
||||
const { data } = imageData;
|
||||
|
||||
const histogramData: HistogramData = {
|
||||
red: new Uint32Array(256),
|
||||
green: new Uint32Array(256),
|
||||
blue: new Uint32Array(256),
|
||||
luminosity: new Uint32Array(256),
|
||||
};
|
||||
|
||||
const totalPixels = data.length / 4;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
|
||||
histogramData.red[r]++;
|
||||
histogramData.green[g]++;
|
||||
histogramData.blue[b]++;
|
||||
|
||||
const luminosity = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
|
||||
histogramData.luminosity[luminosity]++;
|
||||
}
|
||||
|
||||
return {
|
||||
data: histogramData,
|
||||
statistics: {
|
||||
red: calculateStatistics(histogramData.red, totalPixels),
|
||||
green: calculateStatistics(histogramData.green, totalPixels),
|
||||
blue: calculateStatistics(histogramData.blue, totalPixels),
|
||||
luminosity: calculateStatistics(histogramData.luminosity, totalPixels),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getColorInfo(r: number, g: number, b: number): ColorInfo {
|
||||
const rNorm = r / 255;
|
||||
const gNorm = g / 255;
|
||||
const bNorm = b / 255;
|
||||
|
||||
const max = Math.max(rNorm, gNorm, bNorm);
|
||||
const min = Math.min(rNorm, gNorm, bNorm);
|
||||
const delta = max - min;
|
||||
|
||||
let h = 0;
|
||||
if (delta !== 0) {
|
||||
if (max === rNorm) {
|
||||
h = ((gNorm - bNorm) / delta + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||
} else if (max === gNorm) {
|
||||
h = ((bNorm - rNorm) / delta + 2) / 6;
|
||||
} else {
|
||||
h = ((rNorm - gNorm) / delta + 4) / 6;
|
||||
}
|
||||
}
|
||||
|
||||
const l = (max + min) / 2;
|
||||
const sHsl = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
|
||||
|
||||
const sBrightness = max === 0 ? 0 : delta / max;
|
||||
|
||||
const k = 1 - max;
|
||||
const c = max === 0 ? 0 : (1 - rNorm - k) / (1 - k);
|
||||
const m = max === 0 ? 0 : (1 - gNorm - k) / (1 - k);
|
||||
const y = max === 0 ? 0 : (1 - bNorm - k) / (1 - k);
|
||||
|
||||
const xyzR = rNorm > 0.04045 ? Math.pow((rNorm + 0.055) / 1.055, 2.4) : rNorm / 12.92;
|
||||
const xyzG = gNorm > 0.04045 ? Math.pow((gNorm + 0.055) / 1.055, 2.4) : gNorm / 12.92;
|
||||
const xyzB = bNorm > 0.04045 ? Math.pow((bNorm + 0.055) / 1.055, 2.4) : bNorm / 12.92;
|
||||
|
||||
const x = (xyzR * 0.4124564 + xyzG * 0.3575761 + xyzB * 0.1804375) / 0.95047;
|
||||
const yVal = xyzR * 0.2126729 + xyzG * 0.7151522 + xyzB * 0.0721750;
|
||||
const z = (xyzR * 0.0193339 + xyzG * 0.1191920 + xyzB * 0.9503041) / 1.08883;
|
||||
|
||||
const f = (t: number) => t > 0.008856 ? Math.pow(t, 1 / 3) : 7.787 * t + 16 / 116;
|
||||
|
||||
const labL = 116 * f(yVal) - 16;
|
||||
const labA = 500 * (f(x) - f(yVal));
|
||||
const labB = 200 * (f(yVal) - f(z));
|
||||
|
||||
const hex = '#' +
|
||||
r.toString(16).padStart(2, '0') +
|
||||
g.toString(16).padStart(2, '0') +
|
||||
b.toString(16).padStart(2, '0');
|
||||
|
||||
return {
|
||||
rgb: { r, g, b },
|
||||
hsb: {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(sBrightness * 100),
|
||||
b: Math.round(max * 100),
|
||||
},
|
||||
hsl: {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(sHsl * 100),
|
||||
l: Math.round(l * 100),
|
||||
},
|
||||
lab: {
|
||||
l: Math.round(labL),
|
||||
a: Math.round(labA),
|
||||
b: Math.round(labB),
|
||||
},
|
||||
cmyk: {
|
||||
c: Math.round(c * 100),
|
||||
m: Math.round(m * 100),
|
||||
y: Math.round(y * 100),
|
||||
k: Math.round(k * 100),
|
||||
},
|
||||
hex,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderHistogram(
|
||||
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
||||
histogram: Uint32Array,
|
||||
color: string,
|
||||
width: number,
|
||||
height: number,
|
||||
logarithmic: boolean = false
|
||||
): void {
|
||||
const maxValue = Math.max(...histogram);
|
||||
if (maxValue === 0) return;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.globalAlpha = 0.7;
|
||||
|
||||
const barWidth = width / 256;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let normalizedValue: number;
|
||||
if (logarithmic && histogram[i] > 0) {
|
||||
normalizedValue = Math.log10(histogram[i] + 1) / Math.log10(maxValue + 1);
|
||||
} else {
|
||||
normalizedValue = histogram[i] / maxValue;
|
||||
}
|
||||
|
||||
const barHeight = normalizedValue * height;
|
||||
ctx.fillRect(i * barWidth, height - barHeight, barWidth, barHeight);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
export function autoLevels(imageData: ImageData, clipPercent: number = 0.1): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const histogram = calculateHistogram(imageData);
|
||||
const totalPixels = data.length / 4;
|
||||
const clipPixels = Math.round(totalPixels * (clipPercent / 100));
|
||||
|
||||
const findClipPoint = (hist: Uint32Array, fromStart: boolean): number => {
|
||||
let count = 0;
|
||||
if (fromStart) {
|
||||
for (let i = 0; i < 256; i++) {
|
||||
count += hist[i];
|
||||
if (count > clipPixels) return i;
|
||||
}
|
||||
return 0;
|
||||
} else {
|
||||
for (let i = 255; i >= 0; i--) {
|
||||
count += hist[i];
|
||||
if (count > clipPixels) return i;
|
||||
}
|
||||
return 255;
|
||||
}
|
||||
};
|
||||
|
||||
const channels = ['red', 'green', 'blue'] as const;
|
||||
const adjustments = channels.map((channel) => {
|
||||
const hist = histogram.data[channel];
|
||||
const inputBlack = findClipPoint(hist, true);
|
||||
const inputWhite = findClipPoint(hist, false);
|
||||
return { inputBlack, inputWhite };
|
||||
});
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const { inputBlack, inputWhite } = adjustments[c];
|
||||
const range = inputWhite - inputBlack || 1;
|
||||
const value = data[i + c];
|
||||
const adjusted = ((value - inputBlack) / range) * 255;
|
||||
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
||||
}
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function autoContrast(imageData: ImageData): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
let minLum = 255;
|
||||
let maxLum = 0;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const lum = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
if (lum < minLum) minLum = lum;
|
||||
if (lum > maxLum) maxLum = lum;
|
||||
}
|
||||
|
||||
const range = maxLum - minLum || 1;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const adjusted = ((data[i + c] - minLum) / range) * 255;
|
||||
resultData[i + c] = Math.max(0, Math.min(255, Math.round(adjusted)));
|
||||
}
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
export type PhotoFilterPreset =
|
||||
| 'warming-85'
|
||||
| 'warming-81'
|
||||
| 'warming-lba'
|
||||
| 'cooling-80'
|
||||
| 'cooling-82'
|
||||
| 'cooling-lbb'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'cyan'
|
||||
| 'blue'
|
||||
| 'violet'
|
||||
| 'magenta'
|
||||
| 'sepia'
|
||||
| 'deep-red'
|
||||
| 'deep-blue'
|
||||
| 'deep-emerald'
|
||||
| 'deep-yellow'
|
||||
| 'underwater'
|
||||
| 'custom';
|
||||
|
||||
export interface PhotoFilterSettings {
|
||||
filter: PhotoFilterPreset;
|
||||
color: string;
|
||||
density: number;
|
||||
preserveLuminosity: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = {
|
||||
filter: 'warming-85',
|
||||
color: '#ec8a00',
|
||||
density: 25,
|
||||
preserveLuminosity: true,
|
||||
};
|
||||
|
||||
export const PHOTO_FILTER_COLORS: Record<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);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
export interface PosterizeSettings {
|
||||
levels: number;
|
||||
}
|
||||
|
||||
export interface ThresholdSettings {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_POSTERIZE: PosterizeSettings = {
|
||||
levels: 4,
|
||||
};
|
||||
|
||||
export const DEFAULT_THRESHOLD: ThresholdSettings = {
|
||||
level: 128,
|
||||
};
|
||||
|
||||
export function applyPosterize(imageData: ImageData, settings: PosterizeSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const levels = Math.max(2, Math.min(255, Math.round(settings.levels)));
|
||||
const step = 255 / (levels - 1);
|
||||
const divisor = 256 / levels;
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
resultData[i] = Math.round(Math.floor(r / divisor) * step);
|
||||
resultData[i + 1] = Math.round(Math.floor(g / divisor) * step);
|
||||
resultData[i + 2] = Math.round(Math.floor(b / divisor) * step);
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function applyThreshold(imageData: ImageData, settings: ThresholdSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const level = Math.max(0, Math.min(255, settings.level));
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
const luminance = r * 0.299 + g * 0.587 + b * 0.114;
|
||||
const value = luminance >= level ? 255 : 0;
|
||||
|
||||
resultData[i] = value;
|
||||
resultData[i + 1] = value;
|
||||
resultData[i + 2] = value;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
||||
export function applyAdaptiveThreshold(
|
||||
imageData: ImageData,
|
||||
blockSize: number = 11,
|
||||
constant: number = 2
|
||||
): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const grayData = new Uint8Array(width * height);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const idx = i / 4;
|
||||
grayData[idx] = Math.round(data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114);
|
||||
}
|
||||
|
||||
const halfBlock = Math.floor(blockSize / 2);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let sum = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let by = -halfBlock; by <= halfBlock; by++) {
|
||||
for (let bx = -halfBlock; bx <= halfBlock; bx++) {
|
||||
const nx = Math.min(Math.max(x + bx, 0), width - 1);
|
||||
const ny = Math.min(Math.max(y + by, 0), height - 1);
|
||||
sum += grayData[ny * width + nx];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
const mean = sum / count;
|
||||
const threshold = mean - constant;
|
||||
const pixelIdx = y * width + x;
|
||||
const value = grayData[pixelIdx] > threshold ? 255 : 0;
|
||||
|
||||
const i = pixelIdx * 4;
|
||||
resultData[i] = value;
|
||||
resultData[i + 1] = value;
|
||||
resultData[i + 2] = value;
|
||||
resultData[i + 3] = data[i + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
export type SelectiveColorRange =
|
||||
| 'reds'
|
||||
| 'yellows'
|
||||
| 'greens'
|
||||
| 'cyans'
|
||||
| 'blues'
|
||||
| 'magentas'
|
||||
| 'whites'
|
||||
| 'neutrals'
|
||||
| 'blacks';
|
||||
|
||||
export interface SelectiveColorAdjustment {
|
||||
cyan: number;
|
||||
magenta: number;
|
||||
yellow: number;
|
||||
black: number;
|
||||
}
|
||||
|
||||
export interface SelectiveColorSettings {
|
||||
reds: SelectiveColorAdjustment;
|
||||
yellows: SelectiveColorAdjustment;
|
||||
greens: SelectiveColorAdjustment;
|
||||
cyans: SelectiveColorAdjustment;
|
||||
blues: SelectiveColorAdjustment;
|
||||
magentas: SelectiveColorAdjustment;
|
||||
whites: SelectiveColorAdjustment;
|
||||
neutrals: SelectiveColorAdjustment;
|
||||
blacks: SelectiveColorAdjustment;
|
||||
method: 'relative' | 'absolute';
|
||||
}
|
||||
|
||||
const DEFAULT_ADJUSTMENT: SelectiveColorAdjustment = {
|
||||
cyan: 0,
|
||||
magenta: 0,
|
||||
yellow: 0,
|
||||
black: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_SELECTIVE_COLOR: SelectiveColorSettings = {
|
||||
reds: { ...DEFAULT_ADJUSTMENT },
|
||||
yellows: { ...DEFAULT_ADJUSTMENT },
|
||||
greens: { ...DEFAULT_ADJUSTMENT },
|
||||
cyans: { ...DEFAULT_ADJUSTMENT },
|
||||
blues: { ...DEFAULT_ADJUSTMENT },
|
||||
magentas: { ...DEFAULT_ADJUSTMENT },
|
||||
whites: { ...DEFAULT_ADJUSTMENT },
|
||||
neutrals: { ...DEFAULT_ADJUSTMENT },
|
||||
blacks: { ...DEFAULT_ADJUSTMENT },
|
||||
method: 'relative',
|
||||
};
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
return { h: 0, s: 0, l };
|
||||
}
|
||||
|
||||
const d = max - min;
|
||||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
let h: number;
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function getColorRangeWeight(r: number, g: number, b: number, range: SelectiveColorRange): number {
|
||||
const { h, s, l } = rgbToHsl(r, g, b);
|
||||
const hue = h * 360;
|
||||
|
||||
switch (range) {
|
||||
case 'reds':
|
||||
if (s < 0.1) return 0;
|
||||
if ((hue >= 345 || hue <= 15)) return s;
|
||||
if (hue > 15 && hue <= 45) return s * (1 - (hue - 15) / 30);
|
||||
if (hue >= 315 && hue < 345) return s * ((hue - 315) / 30);
|
||||
return 0;
|
||||
|
||||
case 'yellows':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 45 && hue <= 75) return s;
|
||||
if (hue > 15 && hue < 45) return s * ((hue - 15) / 30);
|
||||
if (hue > 75 && hue <= 105) return s * (1 - (hue - 75) / 30);
|
||||
return 0;
|
||||
|
||||
case 'greens':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 105 && hue <= 135) return s;
|
||||
if (hue > 75 && hue < 105) return s * ((hue - 75) / 30);
|
||||
if (hue > 135 && hue <= 165) return s * (1 - (hue - 135) / 30);
|
||||
return 0;
|
||||
|
||||
case 'cyans':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 165 && hue <= 195) return s;
|
||||
if (hue > 135 && hue < 165) return s * ((hue - 135) / 30);
|
||||
if (hue > 195 && hue <= 225) return s * (1 - (hue - 195) / 30);
|
||||
return 0;
|
||||
|
||||
case 'blues':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 225 && hue <= 255) return s;
|
||||
if (hue > 195 && hue < 225) return s * ((hue - 195) / 30);
|
||||
if (hue > 255 && hue <= 285) return s * (1 - (hue - 255) / 30);
|
||||
return 0;
|
||||
|
||||
case 'magentas':
|
||||
if (s < 0.1) return 0;
|
||||
if (hue >= 285 && hue <= 315) return s;
|
||||
if (hue > 255 && hue < 285) return s * ((hue - 255) / 30);
|
||||
if (hue > 315 && hue <= 345) return s * (1 - (hue - 315) / 30);
|
||||
return 0;
|
||||
|
||||
case 'whites':
|
||||
if (l >= 0.8) return (l - 0.8) / 0.2;
|
||||
return 0;
|
||||
|
||||
case 'blacks':
|
||||
if (l <= 0.2) return (0.2 - l) / 0.2;
|
||||
return 0;
|
||||
|
||||
case 'neutrals':
|
||||
if (s < 0.2 && l > 0.2 && l < 0.8) {
|
||||
return (0.2 - s) / 0.2 * Math.min((l - 0.2) / 0.3, (0.8 - l) / 0.3, 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function rgbToCmyk(r: number, g: number, b: number): { c: number; m: number; y: number; k: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const k = 1 - Math.max(r, g, b);
|
||||
if (k === 1) {
|
||||
return { c: 0, m: 0, y: 0, k: 1 };
|
||||
}
|
||||
|
||||
const c = (1 - r - k) / (1 - k);
|
||||
const m = (1 - g - k) / (1 - k);
|
||||
const y = (1 - b - k) / (1 - k);
|
||||
|
||||
return { c, m, y, k };
|
||||
}
|
||||
|
||||
function cmykToRgb(c: number, m: number, y: number, k: number): { r: number; g: number; b: number } {
|
||||
const r = 255 * (1 - c) * (1 - k);
|
||||
const g = 255 * (1 - m) * (1 - k);
|
||||
const b = 255 * (1 - y) * (1 - k);
|
||||
|
||||
return {
|
||||
r: Math.max(0, Math.min(255, Math.round(r))),
|
||||
g: Math.max(0, Math.min(255, Math.round(g))),
|
||||
b: Math.max(0, Math.min(255, Math.round(b))),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySelectiveColor(imageData: ImageData, settings: SelectiveColorSettings): ImageData {
|
||||
const { width, height, data } = imageData;
|
||||
const resultData = new Uint8ClampedArray(data.length);
|
||||
|
||||
const ranges: SelectiveColorRange[] = [
|
||||
'reds', 'yellows', 'greens', 'cyans', 'blues', 'magentas', 'whites', 'neutrals', 'blacks'
|
||||
];
|
||||
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
let { c, m, y, k } = rgbToCmyk(r, g, b);
|
||||
|
||||
for (const range of ranges) {
|
||||
const weight = getColorRangeWeight(r, g, b, range);
|
||||
if (weight <= 0) continue;
|
||||
|
||||
const adj = settings[range];
|
||||
|
||||
if (settings.method === 'relative') {
|
||||
c = c + (adj.cyan / 100) * c * weight;
|
||||
m = m + (adj.magenta / 100) * m * weight;
|
||||
y = y + (adj.yellow / 100) * y * weight;
|
||||
k = k + (adj.black / 100) * k * weight;
|
||||
} else {
|
||||
c = c + (adj.cyan / 100) * weight;
|
||||
m = m + (adj.magenta / 100) * weight;
|
||||
y = y + (adj.yellow / 100) * weight;
|
||||
k = k + (adj.black / 100) * weight;
|
||||
}
|
||||
}
|
||||
|
||||
c = Math.max(0, Math.min(1, c));
|
||||
m = Math.max(0, Math.min(1, m));
|
||||
y = Math.max(0, Math.min(1, y));
|
||||
k = Math.max(0, Math.min(1, k));
|
||||
|
||||
const rgb = cmykToRgb(c, m, y, k);
|
||||
|
||||
resultData[i] = rgb.r;
|
||||
resultData[i + 1] = rgb.g;
|
||||
resultData[i + 2] = rgb.b;
|
||||
resultData[i + 3] = a;
|
||||
}
|
||||
|
||||
return new ImageData(resultData, width, height);
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { parseProject } from './services/project-schema';
|
||||
import { migrateProject, CURRENT_VERSION } from './services/project-migration';
|
||||
|
||||
// ── App smoke tests ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// These tests exercise the integration seam between the project schema,
|
||||
// migration utilities, and the store to confirm the whole pipeline is wired up
|
||||
// and importing correctly.
|
||||
|
||||
describe('OpenReel Image – baseline smoke tests', () => {
|
||||
// Schema is importable.
|
||||
it('project schema module is importable', () => {
|
||||
expect(typeof parseProject).toBe('function');
|
||||
});
|
||||
|
||||
// Migration is importable and exposes the current version constant.
|
||||
it('migration module exposes CURRENT_VERSION', () => {
|
||||
expect(typeof CURRENT_VERSION).toBe('number');
|
||||
expect(CURRENT_VERSION).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// A minimal valid project document passes schema validation.
|
||||
it('validates a minimal valid project', () => {
|
||||
const baseLayer = {
|
||||
id: 'l1',
|
||||
name: 'Layer',
|
||||
type: 'text' as const,
|
||||
visible: true,
|
||||
locked: false,
|
||||
transform: {
|
||||
x: 0, y: 0, width: 200, height: 50, rotation: 0,
|
||||
scaleX: 1, scaleY: 1, skewX: 0, skewY: 0, opacity: 1,
|
||||
},
|
||||
blendMode: { mode: 'normal' as const },
|
||||
shadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 0, offsetY: 4 },
|
||||
innerShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 },
|
||||
stroke: { enabled: false, color: '#000000', width: 1, style: 'solid' as const },
|
||||
glow: { enabled: false, color: '#ffffff', blur: 20, intensity: 1 },
|
||||
filters: {
|
||||
brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0,
|
||||
vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0,
|
||||
blurType: 'gaussian' as const, blurAngle: 0, sharpen: 0, vignette: 0,
|
||||
grain: 0, sepia: 0, invert: 0,
|
||||
},
|
||||
parentId: null,
|
||||
flipHorizontal: false,
|
||||
flipVertical: false,
|
||||
mask: null,
|
||||
clippingMask: false,
|
||||
levels: {
|
||||
enabled: false,
|
||||
master: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
red: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
green: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
blue: { inputBlack: 0, inputWhite: 255, gamma: 1, outputBlack: 0, outputWhite: 255 },
|
||||
},
|
||||
curves: {
|
||||
enabled: false,
|
||||
master: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
red: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
green: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
blue: { points: [{ input: 0, output: 0 }, { input: 255, output: 255 }] },
|
||||
},
|
||||
colorBalance: {
|
||||
enabled: false,
|
||||
shadows: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
midtones: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
highlights: { cyanRed: 0, magentaGreen: 0, yellowBlue: 0 },
|
||||
preserveLuminosity: true,
|
||||
},
|
||||
selectiveColor: {
|
||||
enabled: false, method: 'relative' as const,
|
||||
reds: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
yellows: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
greens: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
cyans: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
blues: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
magentas: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
whites: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
neutrals: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
blacks: { cyan: 0, magenta: 0, yellow: 0, black: 0 },
|
||||
},
|
||||
blackWhite: {
|
||||
enabled: false, reds: 40, yellows: 60, greens: 40, cyans: 60, blues: 20,
|
||||
magentas: 80, tintEnabled: false, tintHue: 35, tintSaturation: 25,
|
||||
},
|
||||
photoFilter: {
|
||||
enabled: false, filter: 'warming-85' as const, color: '#ec8a00',
|
||||
density: 25, preserveLuminosity: true,
|
||||
},
|
||||
channelMixer: {
|
||||
enabled: false, monochrome: false,
|
||||
red: { red: 100, green: 0, blue: 0, constant: 0 },
|
||||
green: { red: 0, green: 100, blue: 0, constant: 0 },
|
||||
blue: { red: 0, green: 0, blue: 100, constant: 0 },
|
||||
},
|
||||
gradientMap: {
|
||||
enabled: false,
|
||||
stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }],
|
||||
reverse: false, dither: false,
|
||||
},
|
||||
posterize: { enabled: false, levels: 4 },
|
||||
threshold: { enabled: false, level: 128 },
|
||||
content: 'Hello',
|
||||
style: {
|
||||
fontFamily: 'Inter', fontSize: 24, fontWeight: 400,
|
||||
fontStyle: 'normal' as const, textDecoration: 'none' as const,
|
||||
textAlign: 'left' as const, verticalAlign: 'top' as const,
|
||||
lineHeight: 1.4, letterSpacing: 0, fillType: 'solid' as const,
|
||||
color: '#ffffff', gradient: null, strokeColor: null, strokeWidth: 0,
|
||||
backgroundColor: null, backgroundPadding: 8, backgroundRadius: 4,
|
||||
textShadow: { enabled: false, color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 0, offsetY: 2 },
|
||||
},
|
||||
autoSize: true,
|
||||
};
|
||||
|
||||
const validProject = {
|
||||
id: 'p1',
|
||||
name: 'Smoke Test',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
artboards: [
|
||||
{
|
||||
id: 'ab1',
|
||||
name: 'Artboard 1',
|
||||
size: { width: 1080, height: 1080 },
|
||||
background: { type: 'color', color: '#ffffff' },
|
||||
layerIds: ['l1'],
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
layers: { l1: baseLayer },
|
||||
assets: {},
|
||||
activeArtboardId: 'ab1',
|
||||
};
|
||||
|
||||
const result = parseProject(validProject);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
// An invalid document is rejected.
|
||||
it('rejects an invalid project document', () => {
|
||||
const result = parseProject({ id: 42, broken: true });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
// Migration promotes a v0 document to v1.
|
||||
it('migrates a v0 project to v1', () => {
|
||||
const v0 = {
|
||||
id: 'old',
|
||||
name: 'Legacy',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
artboards: [{ id: 'ab-old', name: 'Page 1' }],
|
||||
layers: {},
|
||||
assets: {},
|
||||
};
|
||||
|
||||
const migrated = migrateProject(v0 as Record<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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { useState, lazy, Suspense } from 'react';
|
||||
import { Toolbar } from './toolbar/Toolbar';
|
||||
import { LeftPanel } from './panels/LeftPanel';
|
||||
import { Canvas } from './canvas/Canvas';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { LayerPanel } from './layers/LayerPanel';
|
||||
import { HistoryPanel } from './panels/HistoryPanel';
|
||||
import { GuidePanel } from './panels/GuidePanel';
|
||||
import { PagesBar } from './pages/PagesBar';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { Layers, History, Ruler } from 'lucide-react';
|
||||
|
||||
const ExportDialog = lazy(() => import('./ExportDialog').then(m => ({ default: m.ExportDialog })));
|
||||
|
||||
type BottomTab = 'layers' | 'history' | 'guides';
|
||||
|
||||
export function EditorInterface() {
|
||||
const { isPanelCollapsed, isInspectorCollapsed, isExportDialogOpen, closeExportDialog } = useUIStore();
|
||||
const { project } = useProjectStore();
|
||||
const [bottomTab, setBottomTab] = useState<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,626 +0,0 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { Download, FileImage, Loader2, Link2, Link2Off, Printer, Instagram, Youtube, Twitter, Linkedin, Facebook, Image } from 'lucide-react';
|
||||
import { Dialog, DialogFooter } from '../ui/Dialog';
|
||||
import { useProjectStore } from '../../stores/project-store';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import {
|
||||
exportProject,
|
||||
downloadBlob,
|
||||
getExportFilename,
|
||||
type ExportFormat,
|
||||
type ExportQuality,
|
||||
type ExportOptions,
|
||||
} from '../../services/export-service';
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FormatInfo = {
|
||||
id: ExportFormat;
|
||||
name: string;
|
||||
description: string;
|
||||
supportsTransparency: boolean;
|
||||
supportsQuality: boolean;
|
||||
};
|
||||
|
||||
const FORMATS: FormatInfo[] = [
|
||||
{ id: 'png', name: 'PNG', description: 'Lossless, best for graphics', supportsTransparency: true, supportsQuality: false },
|
||||
{ id: 'jpg', name: 'JPG', description: 'Smaller size, photos', supportsTransparency: false, supportsQuality: true },
|
||||
{ id: 'webp', name: 'WebP', description: 'Modern, best compression', supportsTransparency: true, supportsQuality: true },
|
||||
];
|
||||
|
||||
const QUALITY_PRESETS: { id: ExportQuality; name: string; value: number }[] = [
|
||||
{ id: 'low', name: 'Low', value: 60 },
|
||||
{ id: 'medium', name: 'Medium', value: 80 },
|
||||
{ id: 'high', name: 'High', value: 92 },
|
||||
{ id: 'max', name: 'Maximum', value: 100 },
|
||||
];
|
||||
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 1, label: '1x' },
|
||||
{ value: 2, label: '2x' },
|
||||
{ value: 3, label: '3x' },
|
||||
{ value: 4, label: '4x' },
|
||||
];
|
||||
|
||||
const DPI_OPTIONS = [
|
||||
{ value: 72, label: '72 DPI', description: 'Screen' },
|
||||
{ value: 150, label: '150 DPI', description: 'Web print' },
|
||||
{ value: 300, label: '300 DPI', description: 'Print' },
|
||||
{ value: 600, label: '600 DPI', description: 'High quality' },
|
||||
];
|
||||
|
||||
type PlatformPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
maxFileSize?: string;
|
||||
recommendedSize?: { width: number; height: number };
|
||||
description: string;
|
||||
};
|
||||
|
||||
const PLATFORM_PRESETS: PlatformPreset[] = [
|
||||
{
|
||||
id: 'instagram-post',
|
||||
name: 'Instagram Post',
|
||||
icon: Instagram,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1080, height: 1080 },
|
||||
description: 'Square post, max 30MB',
|
||||
},
|
||||
{
|
||||
id: 'instagram-story',
|
||||
name: 'Instagram Story',
|
||||
icon: Instagram,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1080, height: 1920 },
|
||||
description: '9:16 vertical',
|
||||
},
|
||||
{
|
||||
id: 'youtube-thumbnail',
|
||||
name: 'YouTube Thumbnail',
|
||||
icon: Youtube,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
maxFileSize: '2MB',
|
||||
recommendedSize: { width: 1280, height: 720 },
|
||||
description: '16:9, under 2MB',
|
||||
},
|
||||
{
|
||||
id: 'twitter-post',
|
||||
name: 'Twitter/X Post',
|
||||
icon: Twitter,
|
||||
format: 'png',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 675 },
|
||||
description: '16:9 landscape',
|
||||
},
|
||||
{
|
||||
id: 'facebook-post',
|
||||
name: 'Facebook Post',
|
||||
icon: Facebook,
|
||||
format: 'jpg',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 630 },
|
||||
description: '1.91:1 ratio',
|
||||
},
|
||||
{
|
||||
id: 'linkedin-post',
|
||||
name: 'LinkedIn Post',
|
||||
icon: Linkedin,
|
||||
format: 'png',
|
||||
quality: 'high',
|
||||
recommendedSize: { width: 1200, height: 627 },
|
||||
description: 'Professional feed',
|
||||
},
|
||||
{
|
||||
id: 'web-optimized',
|
||||
name: 'Web Optimized',
|
||||
icon: Image,
|
||||
format: 'webp',
|
||||
quality: 'medium',
|
||||
description: 'Smallest file size',
|
||||
},
|
||||
{
|
||||
id: 'print-ready',
|
||||
name: 'Print Ready',
|
||||
icon: Printer,
|
||||
format: 'png',
|
||||
quality: 'max',
|
||||
description: 'Highest quality PNG',
|
||||
},
|
||||
];
|
||||
|
||||
type SizeMode = 'scale' | 'custom' | 'dpi';
|
||||
|
||||
export function ExportDialog({ open, onClose }: ExportDialogProps) {
|
||||
const { project, selectedArtboardId } = useProjectStore();
|
||||
const { showNotification } = useUIStore();
|
||||
|
||||
const [format, setFormat] = useState<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { X, Keyboard } from 'lucide-react';
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: ShortcutItem[];
|
||||
}
|
||||
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Tools',
|
||||
shortcuts: [
|
||||
{ keys: ['V'], description: 'Select tool' },
|
||||
{ keys: ['H'], description: 'Hand/Pan tool' },
|
||||
{ keys: ['T'], description: 'Text tool' },
|
||||
{ keys: ['S'], description: 'Shape tool' },
|
||||
{ keys: ['P'], description: 'Pen tool' },
|
||||
{ keys: ['I'], description: 'Eyedropper' },
|
||||
{ keys: ['Z'], description: 'Zoom tool' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Edit',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', 'Z'], description: 'Undo' },
|
||||
{ keys: ['⌘', '⇧', 'Z'], description: 'Redo' },
|
||||
{ keys: ['⌘', 'C'], description: 'Copy' },
|
||||
{ keys: ['⌘', 'X'], description: 'Cut' },
|
||||
{ keys: ['⌘', 'V'], description: 'Paste' },
|
||||
{ keys: ['⌘', 'D'], description: 'Duplicate' },
|
||||
{ keys: ['Delete'], description: 'Delete selected' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Selection',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', 'A'], description: 'Select all' },
|
||||
{ keys: ['Esc'], description: 'Deselect all' },
|
||||
{ keys: ['⌘', 'G'], description: 'Group layers' },
|
||||
{ keys: ['⌘', '⇧', 'G'], description: 'Ungroup layers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Layer Order',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', ']'], description: 'Bring forward' },
|
||||
{ keys: ['⌘', '['], description: 'Send backward' },
|
||||
{ keys: ['⌘', '⇧', ']'], description: 'Bring to front' },
|
||||
{ keys: ['⌘', '⇧', '['], description: 'Send to back' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'View',
|
||||
shortcuts: [
|
||||
{ keys: ['⌘', '+'], description: 'Zoom in' },
|
||||
{ keys: ['⌘', '-'], description: 'Zoom out' },
|
||||
{ keys: ['⌘', '0'], description: 'Zoom to fit' },
|
||||
{ keys: ["⌘", "'"], description: 'Toggle grid' },
|
||||
{ keys: ['⌘', ';'], description: 'Toggle guides' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Other',
|
||||
shortcuts: [
|
||||
{ keys: ['?'], description: 'Show shortcuts' },
|
||||
{ keys: ['⌘', ','], description: 'Settings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsPanel({ isOpen, onClose }: Props) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { X, Settings, Grid3X3, MousePointer, Save, Palette, Monitor } from 'lucide-react';
|
||||
import { useUIStore } from '../../stores/ui-store';
|
||||
import { Slider } from '@openreel/ui';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SettingsTab = 'canvas' | 'snapping' | 'appearance';
|
||||
|
||||
export function SettingsDialog({ isOpen, onClose }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<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
|
|
@ -1,363 +0,0 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
Clipboard,
|
||||
Scissors,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Lock,
|
||||
Unlock,
|
||||
ArrowUpToLine,
|
||||
ArrowDownToLine,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
RotateCcw,
|
||||
FolderPlus,
|
||||
FolderOpen,
|
||||
Type,
|
||||
Square,
|
||||
Circle,
|
||||
Triangle,
|
||||
Star,
|
||||
Hexagon,
|
||||
Minus,
|
||||
Grid3X3,
|
||||
Ruler,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignStartVertical,
|
||||
AlignCenterVertical,
|
||||
AlignEndVertical,
|
||||
Paintbrush,
|
||||
MousePointer,
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface ContextMenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type ContextMenuType = 'layer' | 'multi-layer' | 'canvas' | 'group';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
shortcut?: string;
|
||||
action: () => void;
|
||||
disabled?: boolean;
|
||||
divider?: boolean;
|
||||
submenu?: MenuItem[];
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
position: ContextMenuPosition;
|
||||
type: ContextMenuType;
|
||||
onClose: () => void;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
onSelectAll: () => void;
|
||||
onToggleVisibility: () => void;
|
||||
onToggleLock: () => void;
|
||||
onBringToFront: () => void;
|
||||
onBringForward: () => void;
|
||||
onSendBackward: () => void;
|
||||
onSendToBack: () => void;
|
||||
onGroup: () => void;
|
||||
onUngroup: () => void;
|
||||
onFlipHorizontal: () => void;
|
||||
onFlipVertical: () => void;
|
||||
onResetTransform: () => void;
|
||||
onCopyStyle: () => void;
|
||||
onPasteStyle: () => void;
|
||||
onAddText: () => void;
|
||||
onAddShape: (type: 'rectangle' | 'ellipse' | 'triangle' | 'star' | 'polygon' | 'line') => void;
|
||||
onToggleGrid: () => void;
|
||||
onToggleRulers: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomFit: () => void;
|
||||
onAlignLeft: () => void;
|
||||
onAlignCenter: () => void;
|
||||
onAlignRight: () => void;
|
||||
onAlignTop: () => void;
|
||||
onAlignMiddle: () => void;
|
||||
onAlignBottom: () => void;
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
showGrid: boolean;
|
||||
showRulers: boolean;
|
||||
hasClipboard: boolean;
|
||||
hasStyleClipboard: boolean;
|
||||
selectedCount: number;
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
position,
|
||||
type,
|
||||
onClose,
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onSelectAll,
|
||||
onToggleVisibility,
|
||||
onToggleLock,
|
||||
onBringToFront,
|
||||
onBringForward,
|
||||
onSendBackward,
|
||||
onSendToBack,
|
||||
onGroup,
|
||||
onUngroup,
|
||||
onFlipHorizontal,
|
||||
onFlipVertical,
|
||||
onResetTransform,
|
||||
onCopyStyle,
|
||||
onPasteStyle,
|
||||
onAddText,
|
||||
onAddShape,
|
||||
onToggleGrid,
|
||||
onToggleRulers,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
onAlignLeft,
|
||||
onAlignCenter,
|
||||
onAlignRight,
|
||||
onAlignTop,
|
||||
onAlignMiddle,
|
||||
onAlignBottom,
|
||||
isVisible,
|
||||
isLocked,
|
||||
showGrid,
|
||||
showRulers,
|
||||
hasClipboard,
|
||||
hasStyleClipboard,
|
||||
selectedCount,
|
||||
}: ContextMenuProps) {
|
||||
const menuRef = useRef<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
|
||||
const RULER_SIZE = 20;
|
||||
const RULER_BG = '#1f1f23';
|
||||
const RULER_TEXT = '#71717a';
|
||||
const RULER_TICK = '#3f3f46';
|
||||
const RULER_HIGHLIGHT = '#3b82f6';
|
||||
|
||||
interface RulersProps {
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
}
|
||||
|
||||
export function Rulers({ containerWidth, containerHeight }: RulersProps) {
|
||||
const horizontalRef = useRef<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;
|
||||
}
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import {
|
||||
AlignHorizontalJustifyStart,
|
||||
AlignHorizontalJustifyCenter,
|
||||
AlignHorizontalJustifyEnd,
|
||||
AlignVerticalJustifyStart,
|
||||
AlignVerticalJustifyCenter,
|
||||
AlignVerticalJustifyEnd,
|
||||
AlignHorizontalSpaceBetween,
|
||||
AlignVerticalSpaceBetween,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layers: Layer[];
|
||||
}
|
||||
|
||||
export function AlignmentSection({ layers }: Props) {
|
||||
const { project, selectedArtboardId, updateLayerTransform } = useProjectStore();
|
||||
|
||||
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
|
||||
|
||||
if (!artboard || layers.length === 0) return null;
|
||||
|
||||
const alignLeft = () => {
|
||||
if (layers.length === 1) {
|
||||
updateLayerTransform(layers[0].id, { x: 0 });
|
||||
} else {
|
||||
const minX = Math.min(...layers.map((l) => l.transform.x));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { x: minX });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignCenterH = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
x: (artboard.size.width - layer.transform.width) / 2,
|
||||
});
|
||||
} else {
|
||||
const bounds = getBounds(layers);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
x: centerX - layer.transform.width / 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignRight = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
x: artboard.size.width - layer.transform.width,
|
||||
});
|
||||
} else {
|
||||
const maxRight = Math.max(...layers.map((l) => l.transform.x + l.transform.width));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
x: maxRight - layer.transform.width,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignTop = () => {
|
||||
if (layers.length === 1) {
|
||||
updateLayerTransform(layers[0].id, { y: 0 });
|
||||
} else {
|
||||
const minY = Math.min(...layers.map((l) => l.transform.y));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { y: minY });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignCenterV = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
y: (artboard.size.height - layer.transform.height) / 2,
|
||||
});
|
||||
} else {
|
||||
const bounds = getBounds(layers);
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
y: centerY - layer.transform.height / 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const alignBottom = () => {
|
||||
if (layers.length === 1) {
|
||||
const layer = layers[0];
|
||||
updateLayerTransform(layer.id, {
|
||||
y: artboard.size.height - layer.transform.height,
|
||||
});
|
||||
} else {
|
||||
const maxBottom = Math.max(...layers.map((l) => l.transform.y + l.transform.height));
|
||||
layers.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, {
|
||||
y: maxBottom - layer.transform.height,
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const distributeH = () => {
|
||||
if (layers.length < 3) return;
|
||||
|
||||
const sorted = [...layers].sort((a, b) => a.transform.x - b.transform.x);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const totalWidth = last.transform.x + last.transform.width - first.transform.x;
|
||||
const layersWidth = sorted.reduce((sum, l) => sum + l.transform.width, 0);
|
||||
const gap = (totalWidth - layersWidth) / (sorted.length - 1);
|
||||
|
||||
let x = first.transform.x;
|
||||
sorted.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { x });
|
||||
x += layer.transform.width + gap;
|
||||
});
|
||||
};
|
||||
|
||||
const distributeV = () => {
|
||||
if (layers.length < 3) return;
|
||||
|
||||
const sorted = [...layers].sort((a, b) => a.transform.y - b.transform.y);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const totalHeight = last.transform.y + last.transform.height - first.transform.y;
|
||||
const layersHeight = sorted.reduce((sum, l) => sum + l.transform.height, 0);
|
||||
const gap = (totalHeight - layersHeight) / (sorted.length - 1);
|
||||
|
||||
let y = first.transform.y;
|
||||
sorted.forEach((layer) => {
|
||||
updateLayerTransform(layer.id, { y });
|
||||
y += layer.transform.height + gap;
|
||||
});
|
||||
};
|
||||
|
||||
const isSingleLayer = layers.length === 1;
|
||||
|
||||
return (
|
||||
<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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer, BlendMode } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const BLEND_MODES: BlendMode['mode'][] = [
|
||||
'normal',
|
||||
'multiply',
|
||||
'screen',
|
||||
'overlay',
|
||||
'darken',
|
||||
'lighten',
|
||||
'color-dodge',
|
||||
'color-burn',
|
||||
'hard-light',
|
||||
'soft-light',
|
||||
'difference',
|
||||
'exclusion',
|
||||
];
|
||||
|
||||
export function AppearanceSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
|
||||
const handleBlendModeChange = (mode: BlendMode['mode']) => {
|
||||
updateLayer(layer.id, { blendMode: { mode } });
|
||||
};
|
||||
|
||||
const handleShadowToggle = () => {
|
||||
updateLayer(layer.id, {
|
||||
shadow: { ...layer.shadow, enabled: !layer.shadow.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleShadowChange = (key: string, value: string | number) => {
|
||||
updateLayer(layer.id, {
|
||||
shadow: { ...layer.shadow, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrokeToggle = () => {
|
||||
updateLayer(layer.id, {
|
||||
stroke: { ...layer.stroke, enabled: !layer.stroke.enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const handleStrokeChange = (key: string, value: string | number) => {
|
||||
updateLayer(layer.id, {
|
||||
stroke: { ...layer.stroke, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Artboard, CanvasBackground } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
artboard: Artboard;
|
||||
}
|
||||
|
||||
export function ArtboardSection({ artboard }: Props) {
|
||||
const { updateArtboard } = useProjectStore();
|
||||
|
||||
const handleSizeChange = (key: 'width' | 'height', value: number) => {
|
||||
updateArtboard(artboard.id, {
|
||||
size: { ...artboard.size, [key]: value },
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackgroundTypeChange = (type: CanvasBackground['type']) => {
|
||||
let background: CanvasBackground;
|
||||
switch (type) {
|
||||
case 'color':
|
||||
background = { type: 'color', color: '#ffffff' };
|
||||
break;
|
||||
case 'transparent':
|
||||
background = { type: 'transparent' };
|
||||
break;
|
||||
case 'gradient':
|
||||
background = {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
type: 'linear',
|
||||
angle: 180,
|
||||
stops: [
|
||||
{ offset: 0, color: '#ffffff' },
|
||||
{ offset: 1, color: '#000000' },
|
||||
],
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
background = { type: 'color', color: '#ffffff' };
|
||||
}
|
||||
updateArtboard(artboard.id, { background });
|
||||
};
|
||||
|
||||
const handleBackgroundColorChange = (color: string) => {
|
||||
updateArtboard(artboard.id, {
|
||||
background: { type: 'color', color },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Wand2, Loader2 } from 'lucide-react';
|
||||
import { Slider } from '@openreel/ui';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
import {
|
||||
getBackgroundRemovalService,
|
||||
BackgroundMode,
|
||||
DEFAULT_OPTIONS,
|
||||
} from '../../../services/background-removal-service';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
export function BackgroundRemovalSection({ layer }: Props) {
|
||||
const { project, addAsset, updateLayer } = useProjectStore();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [mode, setMode] = useState<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { BlackWhiteAdjustment } from '../../../types/adjustments';
|
||||
import { DEFAULT_BLACK_WHITE } from '../../../types/adjustments';
|
||||
import { BLACK_WHITE_PRESETS } from '../../../adjustments/black-white';
|
||||
import { SunMoon, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const COLOR_SLIDERS: { key: keyof BlackWhiteAdjustment; label: string; color: string }[] = [
|
||||
{ key: 'reds', label: 'Reds', color: 'bg-red-500' },
|
||||
{ key: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
|
||||
{ key: 'greens', label: 'Greens', color: 'bg-green-500' },
|
||||
{ key: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
|
||||
{ key: 'blues', label: 'Blues', color: 'bg-blue-500' },
|
||||
{ key: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
|
||||
];
|
||||
|
||||
const PRESET_OPTIONS = [
|
||||
{ id: 'default', label: 'Default' },
|
||||
{ id: 'highContrast', label: 'High Contrast' },
|
||||
{ id: 'infrared', label: 'Infrared' },
|
||||
{ id: 'maximumBlack', label: 'Maximum Black' },
|
||||
{ id: 'maximumWhite', label: 'Maximum White' },
|
||||
{ id: 'neutralDensity', label: 'Neutral Density' },
|
||||
{ id: 'redFilter', label: 'Red Filter' },
|
||||
{ id: 'yellowFilter', label: 'Yellow Filter' },
|
||||
{ id: 'greenFilter', label: 'Green Filter' },
|
||||
{ id: 'blueFilter', label: 'Blue Filter' },
|
||||
] as const;
|
||||
|
||||
function ChannelSlider({ label, color, value, onChange }: { label: string; color: string; value: number; onChange: (v: number) => void }) {
|
||||
const percentage = ((value + 200) / 400) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Droplets, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function BlurSharpenToolPanel() {
|
||||
const { blurSharpenSettings, setBlurSharpenSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setBlurSharpenSettings({
|
||||
size: 30,
|
||||
strength: 50,
|
||||
mode: 'blur',
|
||||
sampleAllLayers: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Paintbrush, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function BrushToolPanel() {
|
||||
const { brushSettings, setBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setBrushSettings({
|
||||
size: 20,
|
||||
hardness: 100,
|
||||
opacity: 1,
|
||||
flow: 1,
|
||||
color: '#000000',
|
||||
blendMode: 'normal',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { ChannelMixerAdjustment, ChannelMixerChannel } from '../../../types/adjustments';
|
||||
import { DEFAULT_CHANNEL_MIXER } from '../../../types/adjustments';
|
||||
import { Blend, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type OutputChannel = 'red' | 'green' | 'blue';
|
||||
|
||||
const CHANNEL_COLORS: Record<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Stamp, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function CloneStampToolPanel() {
|
||||
const { cloneStampSettings, setCloneStampSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setCloneStampSettings({
|
||||
size: 30,
|
||||
hardness: 50,
|
||||
opacity: 1,
|
||||
flow: 1,
|
||||
aligned: true,
|
||||
sampleAllLayers: false,
|
||||
sourcePoint: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { ColorBalanceValues } from '../../../types/adjustments';
|
||||
import { DEFAULT_COLOR_BALANCE } from '../../../types/adjustments';
|
||||
import { Palette, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ToneType = 'shadows' | 'midtones' | 'highlights';
|
||||
|
||||
interface BalanceSliderProps {
|
||||
leftLabel: string;
|
||||
rightLabel: string;
|
||||
leftColor: string;
|
||||
rightColor: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function BalanceSlider({
|
||||
leftLabel,
|
||||
rightLabel,
|
||||
leftColor,
|
||||
rightColor,
|
||||
value,
|
||||
onChange,
|
||||
}: BalanceSliderProps) {
|
||||
const percentage = ((value + 100) / 200) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { getAllHarmonies, type HarmonyType } from '../../../utils/color-harmony';
|
||||
import { Palette, Copy, Check } from 'lucide-react';
|
||||
import { ColorPalettes, QuickColorSwatches } from '../../ui/ColorPalettes';
|
||||
import { SavedColorsSection } from '../../ui/SavedColorsSection';
|
||||
import { useColorStore } from '../../../stores/color-store';
|
||||
|
||||
interface Props {
|
||||
baseColor: string;
|
||||
onColorSelect?: (color: string) => void;
|
||||
}
|
||||
|
||||
export function ColorHarmonySection({ baseColor, onColorSelect }: Props) {
|
||||
const [copiedColor, setCopiedColor] = useState<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,308 +0,0 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useUIStore, CropAspectRatio } from '../../../stores/ui-store';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react';
|
||||
|
||||
const imageCache = new Map<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { CurvePoint } from '../../../types/adjustments';
|
||||
import { DEFAULT_CURVES } from '../../../types/adjustments';
|
||||
import { TrendingUp, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ChannelType = 'master' | 'red' | 'green' | 'blue';
|
||||
|
||||
interface CurveEditorProps {
|
||||
points: CurvePoint[];
|
||||
onChange: (points: CurvePoint[]) => void;
|
||||
channel: ChannelType;
|
||||
}
|
||||
|
||||
function CurveEditor({ points, onChange, channel }: CurveEditorProps) {
|
||||
const svgRef = useRef<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Sun, Moon } from 'lucide-react';
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer, Shadow, InnerShadow, Stroke, Glow } from '../../../types/project';
|
||||
import { Slider } from '@openreel/ui';
|
||||
import { ChevronDown, Droplets, Pencil, Sparkles, CircleDot } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type EffectSection = 'shadow' | 'innerShadow' | 'stroke' | 'glow' | null;
|
||||
|
||||
interface EffectHeaderProps {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
function EffectHeader({ icon: Icon, label, enabled, isOpen, onToggle, onEnabledChange }: EffectHeaderProps) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Eraser, Square, Pencil, Circle } from 'lucide-react';
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
unit?: string;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer, Filter } from '../../../types/project';
|
||||
import { Sparkles, Check } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
interface FilterPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'basic' | 'vintage' | 'cinematic' | 'mood';
|
||||
filters: Filter;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
const FILTER_PRESETS: FilterPreset[] = [
|
||||
{
|
||||
id: 'original',
|
||||
name: 'Original',
|
||||
category: 'basic',
|
||||
filters: { brightness: 100, contrast: 100, saturation: 100, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'vivid',
|
||||
name: 'Vivid',
|
||||
category: 'basic',
|
||||
filters: { brightness: 105, contrast: 115, saturation: 130, hue: 0, exposure: 0, vibrance: 30, highlights: 0, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'warm',
|
||||
name: 'Warm',
|
||||
category: 'mood',
|
||||
filters: { brightness: 105, contrast: 105, saturation: 110, hue: 15, exposure: 5, vibrance: 15, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'cool',
|
||||
name: 'Cool',
|
||||
category: 'mood',
|
||||
filters: { brightness: 100, contrast: 105, saturation: 95, hue: -15, exposure: 0, vibrance: 0, highlights: 5, shadows: 0, clarity: 5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'bw',
|
||||
name: 'B&W',
|
||||
category: 'basic',
|
||||
filters: { brightness: 105, contrast: 115, saturation: 0, hue: 0, exposure: 0, vibrance: 0, highlights: 0, shadows: 0, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 20, grain: 5, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'vintage',
|
||||
name: 'Vintage',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 95, contrast: 90, saturation: 75, hue: 20, exposure: -5, vibrance: -10, highlights: -10, shadows: 15, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 30, grain: 15, sepia: 20, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'fade',
|
||||
name: 'Fade',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 110, contrast: 85, saturation: 80, hue: 0, exposure: 5, vibrance: -5, highlights: 10, shadows: 20, clarity: -10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 15, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'dramatic',
|
||||
name: 'Dramatic',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 95, contrast: 130, saturation: 90, hue: 0, exposure: -5, vibrance: 10, highlights: -15, shadows: -10, clarity: 25, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 15, vignette: 25, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'moody',
|
||||
name: 'Moody',
|
||||
category: 'mood',
|
||||
filters: { brightness: 90, contrast: 110, saturation: 85, hue: -10, exposure: -10, vibrance: 0, highlights: -20, shadows: 5, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 35, grain: 5, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'bright',
|
||||
name: 'Bright',
|
||||
category: 'basic',
|
||||
filters: { brightness: 120, contrast: 105, saturation: 105, hue: 0, exposure: 15, vibrance: 10, highlights: 10, shadows: 20, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'sepia',
|
||||
name: 'Sepia',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 105, contrast: 95, saturation: 40, hue: 35, exposure: 0, vibrance: -20, highlights: 0, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 20, grain: 10, sepia: 50, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'cinematic',
|
||||
name: 'Cinematic',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 95, contrast: 115, saturation: 95, hue: -5, exposure: 0, vibrance: 5, highlights: -10, shadows: 5, clarity: 15, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 10, vignette: 20, grain: 3, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'pop',
|
||||
name: 'Pop',
|
||||
category: 'mood',
|
||||
filters: { brightness: 110, contrast: 120, saturation: 140, hue: 5, exposure: 5, vibrance: 40, highlights: 5, shadows: 0, clarity: 10, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 5, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'matte',
|
||||
name: 'Matte',
|
||||
category: 'cinematic',
|
||||
filters: { brightness: 105, contrast: 85, saturation: 90, hue: 0, exposure: 0, vibrance: -5, highlights: 5, shadows: 15, clarity: -5, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 10, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'retro',
|
||||
name: 'Retro',
|
||||
category: 'vintage',
|
||||
filters: { brightness: 100, contrast: 95, saturation: 70, hue: 25, exposure: -5, vibrance: -15, highlights: -5, shadows: 10, clarity: 0, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 0, vignette: 25, grain: 20, sepia: 15, invert: 0 },
|
||||
},
|
||||
{
|
||||
id: 'punch',
|
||||
name: 'Punch',
|
||||
category: 'basic',
|
||||
filters: { brightness: 100, contrast: 125, saturation: 115, hue: 0, exposure: 0, vibrance: 20, highlights: 0, shadows: -10, clarity: 20, blur: 0, blurType: 'gaussian', blurAngle: 0, sharpen: 20, vignette: 0, grain: 0, sepia: 0, invert: 0 },
|
||||
},
|
||||
];
|
||||
|
||||
function filtersMatch(a: Filter, b: Filter): boolean {
|
||||
return (
|
||||
a.brightness === b.brightness &&
|
||||
a.contrast === b.contrast &&
|
||||
a.saturation === b.saturation &&
|
||||
a.hue === b.hue &&
|
||||
a.exposure === b.exposure &&
|
||||
a.vibrance === b.vibrance &&
|
||||
a.highlights === b.highlights &&
|
||||
a.shadows === b.shadows &&
|
||||
a.clarity === b.clarity &&
|
||||
a.blur === b.blur &&
|
||||
a.blurType === b.blurType &&
|
||||
a.blurAngle === b.blurAngle &&
|
||||
a.sharpen === b.sharpen &&
|
||||
a.vignette === b.vignette &&
|
||||
a.grain === b.grain &&
|
||||
a.sepia === b.sepia &&
|
||||
a.invert === b.invert
|
||||
);
|
||||
}
|
||||
|
||||
function interpolateFilters(target: Filter, intensity: number): Filter {
|
||||
const lerp = (defaultVal: number, targetVal: number) => defaultVal + (targetVal - defaultVal) * (intensity / 100);
|
||||
return {
|
||||
brightness: Math.round(lerp(100, target.brightness)),
|
||||
contrast: Math.round(lerp(100, target.contrast)),
|
||||
saturation: Math.round(lerp(100, target.saturation)),
|
||||
hue: Math.round(lerp(0, target.hue)),
|
||||
exposure: Math.round(lerp(0, target.exposure)),
|
||||
vibrance: Math.round(lerp(0, target.vibrance)),
|
||||
highlights: Math.round(lerp(0, target.highlights)),
|
||||
shadows: Math.round(lerp(0, target.shadows)),
|
||||
clarity: Math.round(lerp(0, target.clarity)),
|
||||
blur: Math.round(lerp(0, target.blur)),
|
||||
blurType: target.blurType,
|
||||
blurAngle: Math.round(lerp(0, target.blurAngle)),
|
||||
sharpen: Math.round(lerp(0, target.sharpen)),
|
||||
vignette: Math.round(lerp(0, target.vignette)),
|
||||
grain: Math.round(lerp(0, target.grain)),
|
||||
sepia: Math.round(lerp(0, target.sepia)),
|
||||
invert: Math.round(lerp(0, target.invert)),
|
||||
};
|
||||
}
|
||||
|
||||
export function FilterPresetsSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [intensity, setIntensity] = useState(100);
|
||||
const [activePresetId, setActivePresetId] = useState<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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { GradientMapStop } from '../../../types/adjustments';
|
||||
import { DEFAULT_GRADIENT_MAP } from '../../../types/adjustments';
|
||||
import { Paintbrush, RotateCcw, Plus, X } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const GRADIENT_PRESETS = [
|
||||
{ name: 'B&W', stops: [{ position: 0, color: '#000000' }, { position: 1, color: '#ffffff' }] },
|
||||
{ name: 'Sepia', stops: [{ position: 0, color: '#2b1810' }, { position: 0.5, color: '#8b5a2b' }, { position: 1, color: '#f5deb3' }] },
|
||||
{ name: 'Duotone Blue', stops: [{ position: 0, color: '#001133' }, { position: 1, color: '#66ccff' }] },
|
||||
{ name: 'Duotone Orange', stops: [{ position: 0, color: '#331100' }, { position: 1, color: '#ff9900' }] },
|
||||
{ name: 'Sunset', stops: [{ position: 0, color: '#1a0533' }, { position: 0.5, color: '#ff6b35' }, { position: 1, color: '#f7c59f' }] },
|
||||
];
|
||||
|
||||
export function GradientMapSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const gradientMap = layer.gradientMap;
|
||||
|
||||
const handleStopChange = (index: number, updates: Partial<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { SquareStack, RotateCcw, X, Plus } from 'lucide-react';
|
||||
|
||||
const gradientTypes = [
|
||||
{ id: 'linear', label: 'Linear' },
|
||||
{ id: 'radial', label: 'Radial' },
|
||||
{ id: 'angle', label: 'Angle' },
|
||||
{ id: 'reflected', label: 'Reflected' },
|
||||
{ id: 'diamond', label: 'Diamond' },
|
||||
] as const;
|
||||
|
||||
export function GradientToolPanel() {
|
||||
const { gradientSettings, setGradientSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setGradientSettings({
|
||||
type: 'linear',
|
||||
colors: ['#000000', '#ffffff'],
|
||||
opacity: 1,
|
||||
reverse: false,
|
||||
dither: true,
|
||||
});
|
||||
};
|
||||
|
||||
const updateColor = (index: number, color: string) => {
|
||||
const newColors = [...gradientSettings.colors];
|
||||
newColors[index] = color;
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const addColor = () => {
|
||||
if (gradientSettings.colors.length >= 5) return;
|
||||
const newColors = [...gradientSettings.colors, '#808080'];
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const removeColor = (index: number) => {
|
||||
if (gradientSettings.colors.length <= 2) return;
|
||||
const newColors = gradientSettings.colors.filter((_, i) => i !== index);
|
||||
setGradientSettings({ colors: newColors });
|
||||
};
|
||||
|
||||
const gradientStyle = `linear-gradient(to right, ${gradientSettings.colors.join(', ')})`;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Bandage, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function HealingBrushToolPanel() {
|
||||
const { healingBrushSettings, setHealingBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setHealingBrushSettings({
|
||||
size: 30,
|
||||
hardness: 50,
|
||||
mode: 'normal',
|
||||
sourcePoint: null,
|
||||
aligned: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,347 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ImageLayer, Filter, BlurType } from '../../../types/project';
|
||||
import { Sun, Contrast, Palette, Thermometer, Focus, Sparkles, CircleDot, Scan, Film, Minus, Move, Target, SunMedium, Vibrate, Sunrise, SunDim, Aperture } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
interface AdjustmentSliderProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
defaultValue: number;
|
||||
onChange: (value: number) => void;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function AdjustmentSlider({ icon, label, value, min, max, defaultValue, onChange, unit = '' }: AdjustmentSliderProps) {
|
||||
const isModified = value !== defaultValue;
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { Crop, ImageIcon } from 'lucide-react';
|
||||
import type { ImageLayer } from '../../../types/project';
|
||||
|
||||
interface Props {
|
||||
layer: ImageLayer;
|
||||
}
|
||||
|
||||
export function ImageControlsSection({ layer }: Props) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,467 +0,0 @@
|
|||
import { memo, lazy, Suspense, useState, createContext, useContext, ReactNode, JSX } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { TransformSection } from './TransformSection';
|
||||
import { AlignmentSection } from './AlignmentSection';
|
||||
import { AppearanceSection } from './AppearanceSection';
|
||||
import { EffectsSection } from './EffectsSection';
|
||||
import { ArtboardSection } from './ArtboardSection';
|
||||
import { PenSettingsSection } from './PenSettingsSection';
|
||||
import { ColorHarmonySection } from './ColorHarmonySection';
|
||||
import { ChevronRight, Sliders, Palette, Wand2, Sparkles, Image as ImageIcon, Layers } from 'lucide-react';
|
||||
import { ScrollArea } from '@openreel/ui';
|
||||
import type { Layer, ImageLayer, TextLayer, ShapeLayer } from '../../../types/project';
|
||||
import type { Tool } from '../../../stores/ui-store';
|
||||
|
||||
const TOOL_FOCUSED_TOOLS = new Set<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);
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { LevelsChannel } from '../../../types/adjustments';
|
||||
import { DEFAULT_LEVELS } from '../../../types/adjustments';
|
||||
import { Activity, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ChannelType = 'master' | 'red' | 'green' | 'blue';
|
||||
|
||||
interface LevelsSliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function LevelsSlider({ label, value, min, max, step = 1, onChange }: LevelsSliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Waves, RotateCcw, ArrowRight, Undo2, Sparkles, RotateCw, RotateCcw as Counterclockwise, Minus, Plus, ArrowLeft, Snowflake, Flame } from 'lucide-react';
|
||||
|
||||
const liquifyTools = [
|
||||
{ id: 'forward-warp', label: 'Forward Warp', icon: ArrowRight },
|
||||
{ id: 'reconstruct', label: 'Reconstruct', icon: Undo2 },
|
||||
{ id: 'smooth', label: 'Smooth', icon: Sparkles },
|
||||
{ id: 'twirl-clockwise', label: 'Twirl CW', icon: RotateCw },
|
||||
{ id: 'twirl-counterclockwise', label: 'Twirl CCW', icon: Counterclockwise },
|
||||
{ id: 'pucker', label: 'Pucker', icon: Minus },
|
||||
{ id: 'bloat', label: 'Bloat', icon: Plus },
|
||||
{ id: 'push-left', label: 'Push Left', icon: ArrowLeft },
|
||||
{ id: 'freeze', label: 'Freeze', icon: Snowflake },
|
||||
{ id: 'thaw', label: 'Thaw', icon: Flame },
|
||||
] as const;
|
||||
|
||||
export function LiquifyToolPanel() {
|
||||
const { liquifySettings, setLiquifySettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setLiquifySettings({
|
||||
brushSize: 100,
|
||||
brushDensity: 50,
|
||||
brushPressure: 100,
|
||||
brushRate: 80,
|
||||
tool: 'forward-warp',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import { useSelectionStore } from '../../../stores/selection-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { LayerMask } from '../../../types/mask';
|
||||
import {
|
||||
Circle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Link,
|
||||
Unlink,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Plus,
|
||||
Download,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { PaintBucket, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function PaintBucketToolPanel() {
|
||||
const { paintBucketSettings, setPaintBucketSettings, brushSettings, setBrushSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setPaintBucketSettings({
|
||||
color: '#000000',
|
||||
tolerance: 32,
|
||||
contiguous: true,
|
||||
antiAlias: true,
|
||||
opacity: 1,
|
||||
fillType: 'foreground',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { PhotoFilterAdjustment } from '../../../types/adjustments';
|
||||
import { DEFAULT_PHOTO_FILTER } from '../../../types/adjustments';
|
||||
import { PHOTO_FILTER_COLORS } from '../../../adjustments/photo-filter';
|
||||
import { SunDim, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ id: 'warming-85', label: 'Warming (85)', group: 'Warming' },
|
||||
{ id: 'warming-81', label: 'Warming (81)', group: 'Warming' },
|
||||
{ id: 'cooling-80', label: 'Cooling (80)', group: 'Cooling' },
|
||||
{ id: 'cooling-82', label: 'Cooling (82)', group: 'Cooling' },
|
||||
{ id: 'custom', label: 'Custom Color', group: 'Custom' },
|
||||
] as const;
|
||||
|
||||
type FilterType = typeof FILTER_OPTIONS[number]['id'];
|
||||
|
||||
export function PhotoFilterSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const photoFilter = layer.photoFilter;
|
||||
|
||||
const handleFilterChange = (filter: FilterType) => {
|
||||
const color = filter === 'custom' ? photoFilter.color : (PHOTO_FILTER_COLORS[filter as keyof typeof PHOTO_FILTER_COLORS] ?? photoFilter.color);
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
filter,
|
||||
color,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDensityChange = (density: number) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
density,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
filter: 'custom',
|
||||
color,
|
||||
} as PhotoFilterAdjustment,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePreserveLuminosityChange = (preserveLuminosity: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
preserveLuminosity,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: {
|
||||
...photoFilter,
|
||||
enabled,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resetPhotoFilter = () => {
|
||||
updateLayer(layer.id, {
|
||||
photoFilter: { ...DEFAULT_PHOTO_FILTER },
|
||||
});
|
||||
};
|
||||
|
||||
const densityPercentage = photoFilter.density;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import { DEFAULT_POSTERIZE } from '../../../types/adjustments';
|
||||
import { Layers, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
export function PosterizeSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const posterize = layer.posterize;
|
||||
|
||||
const handleLevelsChange = (levels: number) => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...posterize, levels },
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnabledChange = (enabled: boolean) => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...posterize, enabled },
|
||||
});
|
||||
};
|
||||
|
||||
const resetPosterize = () => {
|
||||
updateLayer(layer.id, {
|
||||
posterize: { ...DEFAULT_POSTERIZE },
|
||||
});
|
||||
};
|
||||
|
||||
const percentage = ((posterize.levels - 2) / 253) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { useSelectionStore } from '../../../stores/selection-store';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import {
|
||||
Square,
|
||||
Circle,
|
||||
Lasso,
|
||||
Pentagon,
|
||||
Wand2,
|
||||
Plus,
|
||||
Minus,
|
||||
BoxSelect,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Download,
|
||||
Upload,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
function Slider({ label, value, min, max, step = 1, onChange }: SliderProps) {
|
||||
const percentage = ((value - min) / (max - min)) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { Layer } from '../../../types/project';
|
||||
import type { SelectiveColorValues, SelectiveColorAdjustment } from '../../../types/adjustments';
|
||||
import { DEFAULT_SELECTIVE_COLOR } from '../../../types/adjustments';
|
||||
import { Palette, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
layer: Layer;
|
||||
}
|
||||
|
||||
type ColorRange = 'reds' | 'yellows' | 'greens' | 'cyans' | 'blues' | 'magentas' | 'whites' | 'neutrals' | 'blacks';
|
||||
|
||||
const COLOR_RANGES: { id: ColorRange; label: string; color: string }[] = [
|
||||
{ id: 'reds', label: 'Reds', color: 'bg-red-500' },
|
||||
{ id: 'yellows', label: 'Yellows', color: 'bg-yellow-500' },
|
||||
{ id: 'greens', label: 'Greens', color: 'bg-green-500' },
|
||||
{ id: 'cyans', label: 'Cyans', color: 'bg-cyan-500' },
|
||||
{ id: 'blues', label: 'Blues', color: 'bg-blue-500' },
|
||||
{ id: 'magentas', label: 'Magentas', color: 'bg-pink-500' },
|
||||
{ id: 'whites', label: 'Whites', color: 'bg-white border border-border' },
|
||||
{ id: 'neutrals', label: 'Neutrals', color: 'bg-gray-500' },
|
||||
{ id: 'blacks', label: 'Blacks', color: 'bg-gray-900' },
|
||||
];
|
||||
|
||||
function ColorSlider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
|
||||
const percentage = ((value + 100) / 200) * 100;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,524 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { ShapeLayer, ShapeStyle, Gradient, FillType, StrokeDashType, NoiseFill } from '../../../types/project';
|
||||
import { DEFAULT_NOISE_FILL } from '../../../types/project';
|
||||
import { Slider } from '@openreel/ui';
|
||||
import { GradientPicker } from '../../ui/GradientPicker';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@openreel/ui';
|
||||
import { ChevronDown, Link, Unlink } from 'lucide-react';
|
||||
|
||||
const DASH_PATTERNS: { value: StrokeDashType; label: string; preview: string }[] = [
|
||||
{ value: 'solid', label: 'Solid', preview: '━━━━━━' },
|
||||
{ value: 'dashed', label: 'Dashed', preview: '─ ─ ─ ─' },
|
||||
{ value: 'dotted', label: 'Dotted', preview: '· · · · ·' },
|
||||
{ value: 'dash-dot', label: 'Dash-Dot', preview: '─ · ─ ·' },
|
||||
{ value: 'long-dash', label: 'Long Dash', preview: '── ── ──' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
layer: ShapeLayer;
|
||||
}
|
||||
|
||||
export function ShapeSection({ layer }: Props) {
|
||||
const { updateLayer } = useProjectStore();
|
||||
const [isFillOpen, setIsFillOpen] = useState(true);
|
||||
const [isStrokeOpen, setIsStrokeOpen] = useState(false);
|
||||
|
||||
const handleStyleChange = (updates: Partial<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Blend, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function SmudgeToolPanel() {
|
||||
const { smudgeSettings, setSmudgeSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setSmudgeSettings({
|
||||
size: 30,
|
||||
strength: 50,
|
||||
fingerPainting: false,
|
||||
sampleAllLayers: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Droplet, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function SpongeToolPanel() {
|
||||
const { spongeSettings, setSpongeSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setSpongeSettings({
|
||||
size: 30,
|
||||
flow: 50,
|
||||
mode: 'desaturate',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { useUIStore } from '../../../stores/ui-store';
|
||||
import { Bandage, RotateCcw } from 'lucide-react';
|
||||
|
||||
export function SpotHealingToolPanel() {
|
||||
const { spotHealingSettings, setSpotHealingSettings } = useUIStore();
|
||||
|
||||
const resetSettings = () => {
|
||||
setSpotHealingSettings({
|
||||
size: 30,
|
||||
type: 'content-aware',
|
||||
sampleAllLayers: false,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
import { useProjectStore } from '../../../stores/project-store';
|
||||
import type { TextLayer, TextStyle, TextFillType, Gradient } from '../../../types/project';
|
||||
import { AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, CaseUpper, CaseLower, CaseSensitive, Strikethrough, Type } from 'lucide-react';
|
||||
import { FontPicker } from '../../ui/FontPicker';
|
||||
import { GradientPicker } from '../../ui/GradientPicker';
|
||||
import { Slider, Switch } from '@openreel/ui';
|
||||
|
||||
interface Props {
|
||||
layer: TextLayer;
|
||||
}
|
||||
|
||||
const FONT_SIZES = [8, 10, 12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 96, 128];
|
||||
|
||||
interface TextPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
style: Partial<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
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
|
|
@ -1,367 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,430 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,296 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,381 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
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[],
|
||||
};
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,471 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,483 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
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);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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}`);
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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>
|
||||
);
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,755 +0,0 @@
|
|||
import type { Project, Artboard, Layer, ImageLayer, TextLayer, ShapeLayer, Filter } from '../types/project';
|
||||
|
||||
export type ExportFormat = 'png' | 'jpg' | 'webp' | 'svg' | 'pdf';
|
||||
export type ExportQuality = 'low' | 'medium' | 'high' | 'max';
|
||||
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat;
|
||||
quality: ExportQuality;
|
||||
scale: number;
|
||||
background: 'include' | 'transparent';
|
||||
artboardIds?: string[];
|
||||
}
|
||||
|
||||
const QUALITY_MAP: Record<ExportQuality, number> = {
|
||||
low: 0.6,
|
||||
medium: 0.8,
|
||||
high: 0.92,
|
||||
max: 1.0,
|
||||
};
|
||||
|
||||
const BLEND_MODE_MAP: Record<string, 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',
|
||||
};
|
||||
|
||||
export async function exportProject(
|
||||
project: Project,
|
||||
options: ExportOptions,
|
||||
onProgress?: (progress: number, message: string) => void
|
||||
): Promise<Blob[]> {
|
||||
const blobs: Blob[] = [];
|
||||
const artboards = options.artboardIds
|
||||
? project.artboards.filter((a) => options.artboardIds!.includes(a.id))
|
||||
: project.artboards;
|
||||
|
||||
for (let i = 0; i < artboards.length; i++) {
|
||||
const artboard = artboards[i];
|
||||
onProgress?.((i / artboards.length) * 100, `Exporting ${artboard.name}...`);
|
||||
|
||||
const blob = await exportArtboard(project, artboard, options);
|
||||
blobs.push(blob);
|
||||
}
|
||||
|
||||
onProgress?.(100, 'Export complete');
|
||||
return blobs;
|
||||
}
|
||||
|
||||
export async function exportArtboard(
|
||||
project: Project,
|
||||
artboard: Artboard,
|
||||
options: ExportOptions
|
||||
): Promise<Blob> {
|
||||
const { scale, format, quality, background } = options;
|
||||
const width = artboard.size.width * scale;
|
||||
const height = artboard.size.height * scale;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
if (background === 'include' || format === 'jpg') {
|
||||
if (artboard.background.type === 'color') {
|
||||
ctx.fillStyle = artboard.background.color ?? '#ffffff';
|
||||
ctx.fillRect(0, 0, artboard.size.width, artboard.size.height);
|
||||
} else if (artboard.background.type === 'transparent' && format === 'jpg') {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, artboard.size.width, artboard.size.height);
|
||||
} else if (artboard.background.type === 'gradient' && artboard.background.gradient) {
|
||||
const { gradient } = artboard.background;
|
||||
const rad = (gradient.angle * Math.PI) / 180;
|
||||
const x1 = artboard.size.width / 2 - Math.cos(rad) * artboard.size.width / 2;
|
||||
const y1 = artboard.size.height / 2 - Math.sin(rad) * artboard.size.height / 2;
|
||||
const x2 = artboard.size.width / 2 + Math.cos(rad) * artboard.size.width / 2;
|
||||
const y2 = artboard.size.height / 2 + Math.sin(rad) * artboard.size.height / 2;
|
||||
|
||||
const grad = ctx.createLinearGradient(x1, y1, x2, y2);
|
||||
gradient.stops.forEach((stop) => {
|
||||
grad.addColorStop(stop.offset, stop.color);
|
||||
});
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, artboard.size.width, artboard.size.height);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedLayerIds = [...artboard.layerIds].reverse();
|
||||
for (const layerId of sortedLayerIds) {
|
||||
const layer = project.layers[layerId];
|
||||
if (!layer || !layer.visible) continue;
|
||||
await renderLayerToContext(ctx, layer, project);
|
||||
}
|
||||
|
||||
const mimeType = format === 'jpg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png';
|
||||
const qualityValue = format === 'png' ? undefined : QUALITY_MAP[quality];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => resolve(blob!),
|
||||
mimeType,
|
||||
qualityValue
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function renderLayerToContext(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
project: Project
|
||||
): Promise<void> {
|
||||
const { transform } = layer;
|
||||
const shadow = layer.shadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 0, offsetY: 4 };
|
||||
const innerShadow = layer.innerShadow ?? { enabled: false, color: 'rgba(0, 0, 0, 0.5)', blur: 10, offsetX: 2, offsetY: 2 };
|
||||
const glow = layer.glow ?? { enabled: false, color: '#ffffff', blur: 20, intensity: 1 };
|
||||
const blendMode = layer.blendMode?.mode ?? 'normal';
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(transform.x, transform.y);
|
||||
ctx.rotate((transform.rotation * Math.PI) / 180);
|
||||
ctx.scale(transform.scaleX, transform.scaleY);
|
||||
|
||||
const skewX = transform.skewX ?? 0;
|
||||
const skewY = transform.skewY ?? 0;
|
||||
if (skewX !== 0 || skewY !== 0) {
|
||||
ctx.transform(1, Math.tan(skewY * Math.PI / 180), Math.tan(skewX * Math.PI / 180), 1, 0, 0);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = transform.opacity;
|
||||
ctx.globalCompositeOperation = BLEND_MODE_MAP[blendMode] ?? 'source-over';
|
||||
|
||||
if (glow.enabled && glow.blur > 0) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = glow.color;
|
||||
ctx.shadowBlur = glow.blur * (glow.intensity ?? 1);
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await renderLayerContent(ctx, layer, project);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (shadow.enabled) {
|
||||
ctx.shadowColor = shadow.color;
|
||||
ctx.shadowBlur = shadow.blur;
|
||||
ctx.shadowOffsetX = shadow.offsetX;
|
||||
ctx.shadowOffsetY = shadow.offsetY;
|
||||
}
|
||||
|
||||
await renderLayerContent(ctx, layer, project);
|
||||
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
|
||||
if (innerShadow.enabled && innerShadow.blur > 0) {
|
||||
renderInnerShadow(ctx, layer, innerShadow);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
async function renderLayerContent(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
project: Project
|
||||
): Promise<void> {
|
||||
switch (layer.type) {
|
||||
case 'image':
|
||||
await renderImageLayerToContext(ctx, layer as ImageLayer, project);
|
||||
break;
|
||||
case 'text':
|
||||
renderTextLayerToContext(ctx, layer as TextLayer);
|
||||
break;
|
||||
case 'shape':
|
||||
renderShapeLayerToContext(ctx, layer as ShapeLayer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function renderInnerShadow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: Layer,
|
||||
innerShadow: { color: string; blur: number; offsetX: number; offsetY: number }
|
||||
): void {
|
||||
const { width, height } = layer.transform;
|
||||
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(0, 0, width, height);
|
||||
ctx.clip();
|
||||
|
||||
ctx.shadowColor = innerShadow.color;
|
||||
ctx.shadowBlur = innerShadow.blur;
|
||||
ctx.shadowOffsetX = innerShadow.offsetX;
|
||||
ctx.shadowOffsetY = innerShadow.offsetY;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
|
||||
const spread = innerShadow.blur + Math.max(Math.abs(innerShadow.offsetX), Math.abs(innerShadow.offsetY)) + 50;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-spread, -spread);
|
||||
ctx.lineTo(width + spread, -spread);
|
||||
ctx.lineTo(width + spread, height + spread);
|
||||
ctx.lineTo(-spread, height + spread);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.moveTo(0, 0);
|
||||
ctx.lineTo(0, height);
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(width, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fill('evenodd');
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function applyFilters(ctx: CanvasRenderingContext2D, filters: Filter): void {
|
||||
const filterParts: string[] = [];
|
||||
|
||||
let effectiveBrightness = filters.brightness;
|
||||
if (filters.exposure !== 0) {
|
||||
effectiveBrightness = effectiveBrightness * (1 + filters.exposure / 100);
|
||||
}
|
||||
if (filters.highlights > 0) {
|
||||
effectiveBrightness = effectiveBrightness * (1 + filters.highlights / 200);
|
||||
}
|
||||
if (filters.shadows > 0) {
|
||||
effectiveBrightness = effectiveBrightness * (1 + filters.shadows / 300);
|
||||
}
|
||||
if (effectiveBrightness !== 100) {
|
||||
filterParts.push(`brightness(${Math.round(effectiveBrightness)}%)`);
|
||||
}
|
||||
|
||||
let effectiveContrast = filters.contrast;
|
||||
if (filters.clarity !== 0) {
|
||||
effectiveContrast = effectiveContrast * (1 + filters.clarity / 150);
|
||||
}
|
||||
if (filters.highlights < 0) {
|
||||
effectiveContrast = effectiveContrast * (1 + filters.highlights / 400);
|
||||
}
|
||||
if (filters.shadows < 0) {
|
||||
effectiveContrast = effectiveContrast * (1 + filters.shadows / 400);
|
||||
}
|
||||
if (effectiveContrast !== 100) {
|
||||
filterParts.push(`contrast(${Math.round(effectiveContrast)}%)`);
|
||||
}
|
||||
|
||||
let effectiveSaturation = filters.saturation;
|
||||
if (filters.vibrance !== 0) {
|
||||
effectiveSaturation = effectiveSaturation * (1 + filters.vibrance / 150);
|
||||
}
|
||||
if (effectiveSaturation !== 100) {
|
||||
filterParts.push(`saturate(${Math.round(effectiveSaturation)}%)`);
|
||||
}
|
||||
|
||||
if (filters.hue !== 0) {
|
||||
filterParts.push(`hue-rotate(${filters.hue}deg)`);
|
||||
}
|
||||
if (filters.sepia > 0) {
|
||||
filterParts.push(`sepia(${filters.sepia}%)`);
|
||||
}
|
||||
if (filters.invert > 0) {
|
||||
filterParts.push(`invert(${filters.invert}%)`);
|
||||
}
|
||||
if (filters.blur > 0 && filters.blurType === 'gaussian') {
|
||||
filterParts.push(`blur(${filters.blur}px)`);
|
||||
}
|
||||
|
||||
if (filterParts.length > 0) {
|
||||
ctx.filter = filterParts.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
function applyMotionBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
width: number,
|
||||
height: number,
|
||||
amount: number,
|
||||
angle: number
|
||||
): void {
|
||||
const steps = Math.min(Math.ceil(amount / 2), 20);
|
||||
const radians = (angle * Math.PI) / 180;
|
||||
const dx = Math.cos(radians) * (amount / steps);
|
||||
const dy = Math.sin(radians) * (amount / steps);
|
||||
|
||||
for (let i = -steps; i <= steps; i++) {
|
||||
const alpha = 1 / (Math.abs(i) + 1);
|
||||
ctx.globalAlpha = alpha / (steps * 2);
|
||||
ctx.drawImage(img, i * dx, i * dy, width, height);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function applyRadialBlur(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
img: HTMLImageElement,
|
||||
width: number,
|
||||
height: number,
|
||||
amount: number
|
||||
): void {
|
||||
const steps = Math.min(Math.ceil(amount / 2), 15);
|
||||
const centerX = width / 2;
|
||||
const centerY = height / 2;
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const scale = 1 + (i * amount) / (steps * 100);
|
||||
const alpha = 1 / (i + 1);
|
||||
ctx.globalAlpha = alpha / steps;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(centerX, centerY);
|
||||
ctx.scale(scale, scale);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
async function renderImageLayerToContext(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
layer: ImageLayer,
|
||||
project: Project
|
||||
): Promise<void> {
|
||||
const asset = project.assets[layer.sourceId];
|
||||
if (!asset) return;
|
||||
|
||||
const flipH = layer.flipHorizontal ?? false;
|
||||
const flipV = layer.flipVertical ?? false;
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
flipH ? layer.transform.width : 0,
|
||||
flipV ? layer.transform.height : 0
|
||||
);
|
||||
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
const { filters } = layer;
|
||||
|
||||
applyFilters(ctx, filters);
|
||||
|
||||
if (filters.blur > 0 && filters.blurType === 'motion') {
|
||||
applyMotionBlur(ctx, img, layer.transform.width, layer.transform.height, filters.blur, filters.blurAngle);
|
||||
} else if (filters.blur > 0 && filters.blurType === 'radial') {
|
||||
applyRadialBlur(ctx, img, layer.transform.width, layer.transform.height, filters.blur);
|
||||
} else {
|
||||
ctx.drawImage(img, 0, 0, layer.transform.width, layer.transform.height);
|
||||
}
|
||||
|
||||
ctx.filter = 'none';
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => resolve();
|
||||
img.src = asset.dataUrl ?? asset.blobUrl ?? '';
|
||||
});
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function renderTextLayerToContext(ctx: CanvasRenderingContext2D, layer: TextLayer): void {
|
||||
const { style, content, transform } = layer;
|
||||
const flipH = layer.flipHorizontal ?? false;
|
||||
const flipV = layer.flipVertical ?? false;
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
flipH ? transform.width : 0,
|
||||
flipV ? transform.height : 0
|
||||
);
|
||||
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
||||
}
|
||||
|
||||
ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize}px ${style.fontFamily}`;
|
||||
ctx.textAlign = style.textAlign as CanvasTextAlign;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const lines = content.split('\n');
|
||||
const lineHeight = style.fontSize * style.lineHeight;
|
||||
|
||||
let textX = 0;
|
||||
if (style.textAlign === 'center') textX = transform.width / 2;
|
||||
else if (style.textAlign === 'right') textX = transform.width;
|
||||
|
||||
if (style.backgroundColor) {
|
||||
const padding = style.backgroundPadding ?? 8;
|
||||
const radius = style.backgroundRadius ?? 4;
|
||||
const textWidth = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
||||
const textHeight = lines.length * lineHeight;
|
||||
|
||||
let bgX = -padding;
|
||||
if (style.textAlign === 'center') bgX = (transform.width - textWidth) / 2 - padding;
|
||||
else if (style.textAlign === 'right') bgX = transform.width - textWidth - padding;
|
||||
|
||||
ctx.fillStyle = style.backgroundColor;
|
||||
ctx.beginPath();
|
||||
const bgW = textWidth + padding * 2;
|
||||
const bgH = textHeight + padding * 2;
|
||||
const bgY = -padding;
|
||||
const r = Math.min(radius, bgW / 2, bgH / 2);
|
||||
ctx.moveTo(bgX + r, bgY);
|
||||
ctx.lineTo(bgX + bgW - r, bgY);
|
||||
ctx.quadraticCurveTo(bgX + bgW, bgY, bgX + bgW, bgY + r);
|
||||
ctx.lineTo(bgX + bgW, bgY + bgH - r);
|
||||
ctx.quadraticCurveTo(bgX + bgW, bgY + bgH, bgX + bgW - r, bgY + bgH);
|
||||
ctx.lineTo(bgX + r, bgY + bgH);
|
||||
ctx.quadraticCurveTo(bgX, bgY + bgH, bgX, bgY + bgH - r);
|
||||
ctx.lineTo(bgX, bgY + r);
|
||||
ctx.quadraticCurveTo(bgX, bgY, bgX + r, bgY);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
let fillStyle: string | CanvasGradient = style.color;
|
||||
if (style.fillType === 'gradient' && style.gradient && lines.length > 0) {
|
||||
const lineWidths = lines.map((line) => ctx.measureText(line).width);
|
||||
const textWidth = lineWidths.length > 0 ? Math.max(...lineWidths) : 0;
|
||||
const textHeight = lines.length * lineHeight;
|
||||
|
||||
if (textWidth > 0 && textHeight > 0) {
|
||||
let gradientStartX = 0;
|
||||
if (style.textAlign === 'center') gradientStartX = (transform.width - textWidth) / 2;
|
||||
else if (style.textAlign === 'right') gradientStartX = transform.width - textWidth;
|
||||
|
||||
if (style.gradient.type === 'linear') {
|
||||
const angleRad = (style.gradient.angle * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const halfWidth = textWidth / 2;
|
||||
const halfHeight = textHeight / 2;
|
||||
const len = Math.abs(halfWidth * cos) + Math.abs(halfHeight * sin);
|
||||
|
||||
const centerX = gradientStartX + halfWidth;
|
||||
const centerY = halfHeight;
|
||||
const gradient = ctx.createLinearGradient(
|
||||
centerX - len * cos,
|
||||
centerY - len * sin,
|
||||
centerX + len * cos,
|
||||
centerY + len * sin
|
||||
);
|
||||
style.gradient.stops.forEach((stop) => {
|
||||
gradient.addColorStop(stop.offset, stop.color);
|
||||
});
|
||||
fillStyle = gradient;
|
||||
} else {
|
||||
const centerX = gradientStartX + textWidth / 2;
|
||||
const centerY = textHeight / 2;
|
||||
const radius = Math.max(textWidth, textHeight) / 2;
|
||||
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
style.gradient.stops.forEach((stop) => {
|
||||
gradient.addColorStop(stop.offset, stop.color);
|
||||
});
|
||||
fillStyle = gradient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textShadow = style.textShadow;
|
||||
if (textShadow?.enabled) {
|
||||
ctx.shadowColor = textShadow.color ?? 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.shadowBlur = textShadow.blur ?? 4;
|
||||
ctx.shadowOffsetX = textShadow.offsetX ?? 0;
|
||||
ctx.shadowOffsetY = textShadow.offsetY ?? 2;
|
||||
}
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const y = i * lineHeight;
|
||||
|
||||
if (style.strokeColor && (style.strokeWidth ?? 0) > 0) {
|
||||
ctx.strokeStyle = style.strokeColor;
|
||||
ctx.lineWidth = style.strokeWidth ?? 1;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.miterLimit = 2;
|
||||
ctx.strokeText(line, textX, y);
|
||||
}
|
||||
|
||||
ctx.fillStyle = fillStyle;
|
||||
ctx.fillText(line, textX, y);
|
||||
});
|
||||
|
||||
if (textShadow?.enabled) {
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowOffsetX = 0;
|
||||
ctx.shadowOffsetY = 0;
|
||||
}
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function renderShapeLayerToContext(ctx: CanvasRenderingContext2D, layer: ShapeLayer): void {
|
||||
const { shapeType, shapeStyle, transform } = layer;
|
||||
const { width, height } = transform;
|
||||
const flipH = layer.flipHorizontal ?? false;
|
||||
const flipV = layer.flipVertical ?? false;
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.save();
|
||||
ctx.translate(
|
||||
flipH ? width : 0,
|
||||
flipV ? height : 0
|
||||
);
|
||||
ctx.scale(flipH ? -1 : 1, flipV ? -1 : 1);
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
switch (shapeType) {
|
||||
case 'rectangle': {
|
||||
let tl = 0, tr = 0, br = 0, bl = 0;
|
||||
|
||||
if (shapeStyle.individualCorners && shapeStyle.corners) {
|
||||
tl = Math.min(shapeStyle.corners.topLeft, width / 2, height / 2);
|
||||
tr = Math.min(shapeStyle.corners.topRight, width / 2, height / 2);
|
||||
br = Math.min(shapeStyle.corners.bottomRight, width / 2, height / 2);
|
||||
bl = Math.min(shapeStyle.corners.bottomLeft, width / 2, height / 2);
|
||||
} else if (shapeStyle.cornerRadius > 0) {
|
||||
const r = Math.min(shapeStyle.cornerRadius, width / 2, height / 2);
|
||||
tl = tr = br = bl = r;
|
||||
}
|
||||
|
||||
if (tl > 0 || tr > 0 || br > 0 || bl > 0) {
|
||||
ctx.moveTo(tl, 0);
|
||||
ctx.lineTo(width - tr, 0);
|
||||
if (tr > 0) ctx.quadraticCurveTo(width, 0, width, tr);
|
||||
else ctx.lineTo(width, 0);
|
||||
ctx.lineTo(width, height - br);
|
||||
if (br > 0) ctx.quadraticCurveTo(width, height, width - br, height);
|
||||
else ctx.lineTo(width, height);
|
||||
ctx.lineTo(bl, height);
|
||||
if (bl > 0) ctx.quadraticCurveTo(0, height, 0, height - bl);
|
||||
else ctx.lineTo(0, height);
|
||||
ctx.lineTo(0, tl);
|
||||
if (tl > 0) ctx.quadraticCurveTo(0, 0, tl, 0);
|
||||
else ctx.lineTo(0, 0);
|
||||
ctx.closePath();
|
||||
} else {
|
||||
ctx.rect(0, 0, width, height);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ellipse':
|
||||
ctx.ellipse(width / 2, height / 2, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
break;
|
||||
|
||||
case 'triangle':
|
||||
ctx.moveTo(width / 2, 0);
|
||||
ctx.lineTo(width, height);
|
||||
ctx.lineTo(0, height);
|
||||
ctx.closePath();
|
||||
break;
|
||||
|
||||
case 'polygon': {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const radius = Math.min(width, height) / 2;
|
||||
const sides = layer.sides ?? 6;
|
||||
for (let i = 0; i < sides; i++) {
|
||||
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
||||
const px = cx + radius * Math.cos(angle);
|
||||
const py = cy + radius * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'star': {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
const outerRadius = Math.min(width, height) / 2;
|
||||
const innerRatio = layer.innerRadius ?? 0.4;
|
||||
const innerRadius = outerRadius * innerRatio;
|
||||
const points = layer.sides ?? 5;
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const radius = i % 2 === 0 ? outerRadius : innerRadius;
|
||||
const angle = (i * Math.PI) / points - Math.PI / 2;
|
||||
const px = cx + radius * Math.cos(angle);
|
||||
const py = cy + radius * Math.sin(angle);
|
||||
if (i === 0) ctx.moveTo(px, py);
|
||||
else ctx.lineTo(px, py);
|
||||
}
|
||||
ctx.closePath();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'line':
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width, height / 2);
|
||||
break;
|
||||
|
||||
case 'arrow': {
|
||||
const arrowHeadSize = Math.min(width, height) * 0.3;
|
||||
ctx.moveTo(0, height / 2);
|
||||
ctx.lineTo(width - arrowHeadSize, height / 2);
|
||||
ctx.moveTo(width, height / 2);
|
||||
ctx.lineTo(width - arrowHeadSize, height / 2 - arrowHeadSize / 2);
|
||||
ctx.moveTo(width, height / 2);
|
||||
ctx.lineTo(width - arrowHeadSize, height / 2 + arrowHeadSize / 2);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'path':
|
||||
if (layer.points && layer.points.length > 1) {
|
||||
ctx.moveTo(layer.points[0].x, layer.points[0].y);
|
||||
for (let i = 1; i < layer.points.length; i++) {
|
||||
ctx.lineTo(layer.points[i].x, layer.points[i].y);
|
||||
}
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
ctx.rect(0, 0, width, height);
|
||||
}
|
||||
|
||||
const fillType = shapeStyle.fillType ?? 'solid';
|
||||
const hasFill = fillType === 'solid' ? !!shapeStyle.fill : fillType === 'gradient' ? !!shapeStyle.gradient : fillType === 'noise' ? !!shapeStyle.noise : false;
|
||||
|
||||
if (hasFill) {
|
||||
ctx.globalAlpha *= shapeStyle.fillOpacity;
|
||||
|
||||
if (fillType === 'noise' && shapeStyle.noise) {
|
||||
ctx.fillStyle = shapeStyle.noise.baseColor;
|
||||
ctx.fill();
|
||||
|
||||
ctx.save();
|
||||
ctx.clip();
|
||||
|
||||
const { noise } = shapeStyle;
|
||||
const noiseSize = noise.size;
|
||||
const density = noise.density;
|
||||
|
||||
ctx.fillStyle = noise.noiseColor;
|
||||
for (let y = 0; y < height; y += noiseSize) {
|
||||
for (let x = 0; x < width; x += noiseSize) {
|
||||
if (Math.random() < density) {
|
||||
ctx.fillRect(x, y, noiseSize, noiseSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
} else if (fillType === 'gradient' && shapeStyle.gradient) {
|
||||
let gradient: CanvasGradient;
|
||||
if (shapeStyle.gradient.type === 'linear') {
|
||||
const angleRad = (shapeStyle.gradient.angle * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
const len = Math.abs(halfW * cos) + Math.abs(halfH * sin);
|
||||
gradient = ctx.createLinearGradient(
|
||||
halfW - len * cos,
|
||||
halfH - len * sin,
|
||||
halfW + len * cos,
|
||||
halfH + len * sin
|
||||
);
|
||||
} else {
|
||||
const radius = Math.max(width, height) / 2;
|
||||
gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, radius);
|
||||
}
|
||||
shapeStyle.gradient.stops.forEach((stop) => {
|
||||
gradient.addColorStop(stop.offset, stop.color);
|
||||
});
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
} else if (shapeStyle.fill) {
|
||||
ctx.fillStyle = shapeStyle.fill;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalAlpha /= shapeStyle.fillOpacity;
|
||||
}
|
||||
|
||||
if (shapeStyle.stroke) {
|
||||
ctx.strokeStyle = shapeStyle.stroke;
|
||||
ctx.lineWidth = shapeStyle.strokeWidth;
|
||||
ctx.globalAlpha *= shapeStyle.strokeOpacity;
|
||||
|
||||
const sw = shapeStyle.strokeWidth;
|
||||
switch (shapeStyle.strokeDash ?? 'solid') {
|
||||
case 'dashed':
|
||||
ctx.setLineDash([sw * 3, sw * 2]);
|
||||
break;
|
||||
case 'dotted':
|
||||
ctx.setLineDash([sw, sw * 2]);
|
||||
ctx.lineCap = 'round';
|
||||
break;
|
||||
case 'dash-dot':
|
||||
ctx.setLineDash([sw * 4, sw * 2, sw, sw * 2]);
|
||||
break;
|
||||
case 'long-dash':
|
||||
ctx.setLineDash([sw * 6, sw * 3]);
|
||||
break;
|
||||
default:
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
if (flipH || flipV) {
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function getExportFilename(projectName: string, artboardName: string, format: ExportFormat): string {
|
||||
const safeName = `${projectName}-${artboardName}`.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
return `${safeName}.${format}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue