Compare commits

..

38 commits

Author SHA1 Message Date
910bbf8d3f merge: bring NLE editor pages (editor.html, timeline.js, timecode.js) from main 2026-05-18 23:02:51 -04:00
e8e26dd4d8 fix: remove Google Fonts, fix editor link to :47435, fix page titles
- Remove @import Google Fonts from common.css (was blocking CSS on LAN)
- Update Editor nav link on all pages to dynamically resolve to :47435
  (OpenReel SPA) using inline script so it works on any hostname
- Fix page titles from Wild Dragon -> Z-AMPP across all pages
- Resolver: <a href="#" id="editor-nav-link"> + IIFE sets href at load
2026-05-18 22:56:51 -04:00
1f31d1037d merge: bring sequences/auth/admin backend + auth-guard frontend into fix/library-and-signal-indicator 2026-05-18 21:25:36 -04:00
6bd97a2a03 feat(meme): Token Pricing page with usage chart + AMPP-style Z-AMPP SVG wordmark on home + Tokens tile/nav everywhere 2026-05-18 11:05:30 -04:00
1f4750a1b4 feat(meme): Token Pricing page — gentle ribbing of metered-compute broadcast platforms 2026-05-18 10:56:55 -04:00
c781a469f3 feat(recorders): align with home/projects aesthetic — brand-blue gradient, refreshed cards, tile selectors, slide-panel polish 2026-05-18 10:49:46 -04:00
32bce2e263 feat(editor): splice tool (B/S key + Split button), thumbnail hydration via signed URL, enable Export (draft for now) 2026-05-18 10:25:53 -04:00
3ae150ad53 feat(editor-native): repoint Editor links from openreel (:47435) to in-house /edit.html 2026-05-18 10:18:14 -04:00
2e1bcd655f feat(editor-native): Phase A — single-track editor logic (asset library, preview, in/out markers, drafts) 2026-05-18 10:17:31 -04:00
beb8f31674 feat(editor-native): Phase A — single-track editor shell (HTML scaffold) 2026-05-18 10:16:12 -04:00
a3596265eb feat(brand+home): swap sidebar to Wild Dragon logo, add favicon everywhere, fix home counters (status= not state=) 2026-05-18 10:13:08 -04:00
0e48a8d70f feat(brand): add Wild Dragon logo + favicon 2026-05-18 14:11:29 +00:00
5b557418f8 feat(home): drop Settings tile (not workspace nav; access via topbar gear) 2026-05-18 10:07:53 -04:00
81257b5201 feat(nav): add Home + Projects to sidebar across all pages; redirect login to home.html; bump image cache to v=hardhat3 2026-05-18 10:03:32 -04:00
623e38ae27 feat(home): redesign in AMPP layout — wide preview cards on brand-blue gradient, hardhat avatar centerpiece 2026-05-18 10:01:37 -04:00
1c7329ef35 feat(brand): cleaned hardhat photo (stray sketch lines removed via blob isolation) 2026-05-18 09:59:53 -04:00
efebf38271 feat(projects): project + bin management page (CRUD on /api/v1/projects + /api/v1/bins) 2026-05-18 09:58:34 -04:00
b9879d76b7 feat(home): add Home landing page modeled on AMPP — hardhat hero + workspace tiles 2026-05-18 09:56:20 -04:00
230944fc4b fix(recorders): kill the timer/status flap by computing live values inline + skipping unchanged DOM rebuilds 2026-05-18 09:47:03 -04:00
57116dde42 feat(recorders): stable elapsed timer + live HLS preview on the card; optimistic signal default 2026-05-18 09:40:42 -04:00
57c3871cc1 feat(brand): hardhat photo + Z-AMPP name on every page (library, upload, capture, jobs, recorders, settings) 2026-05-18 09:28:49 -04:00
a9c16d9509 fix(capture): wire bootstrapAutoStart() + add missing captureManager/MAM_API_URL/server (regression from earlier conflict resolution) 2026-05-18 09:25:55 -04:00
d8229e6f3f feat(probe): pre-flight reachability + actionable SRT/RTMP error messages 2026-05-18 07:57:48 -04:00
f181eb6d34 fix(splash): bust image cache + correct aspect ratio so the hardhat photo loads after redeploy 2026-05-18 07:45:59 -04:00
7d76f9c549 feat(growing-files): Phase 1 - live HLS preview during recording
While a recorder is running, the capture container tees an HLS
stream into /live/<assetId>/ alongside the ProRes master upload.
The asset row is pre-created at recorder start with status='live'
so the clip appears in the library immediately. /api/v1/assets/:id/stream
returns the HLS playlist URL until recording stops, then proxy.

* docker-compose: shared wild-dragon-live mount on api/capture/web-ui
* migration 001-add-live-status: idempotent ALTER TYPE for asset_status
* mam-api: runMigrations() on boot; recorders.js pre-creates live asset
  + passes ASSET_ID; assets.js POST upserts on existing live row instead
  of inserting a duplicate, and stream route returns HLS for live assets
* capture: parallel HLS ffmpeg into /live/<assetId>/; ASSET_ID env
* web-ui: nginx serves /live/, preview.js loads hls.js, LIVE badge added
2026-05-18 07:29:50 -04:00
Zac
6a8e4ac250 fix(editor): show loading banner during auto-import so Edit feels responsive
Clicking Edit on the preview modal worked, but the user only saw an empty editor for ~25s while the recovery + format-chooser cycle ran and the bridge waited for a stable project. Looked broken. Now: a centered top banner appears the moment the bridge detects ?asset=, reads Loading clip from Z-AMPP MAM, switches to Clip ready in media bin on success, or surfaces the failure. Project-stability gate tightened from 1500ms to 600ms so the import lands sooner.
2026-05-17 22:44:08 -04:00
Zac
e390f0efab fix(editor): asset auto-import now lands cleanly into the media bin
Three problems were blocking the round-trip. Each fixed.

* MediaBridge.importFromURL went through the file-import service but not the Zustand store, so the media bin stayed empty. mam-bridge now calls window.__projectStore.getState().importMedia(file) which is what the actual UI uses. project-store.ts exposes useProjectStore on window for that hook.
* rustfs serves the proxy with content-type application/octet-stream; the editor rejects with DECODE_ERROR on that mime. Bridge now forces video/mp4 (or audio/wav, video/webm, etc.) based on the asset filename.
* The Recover Your Work modal and the Welcome tour blocked editor initialization. Bridge now auto-clicks Start Fresh and Skip Tour (alongside the format chooser), and waits 1.5s of project-id stability before calling importMedia so it does not get clobbered by the project-replacement cycle. One-shot guard prevents duplicate imports.
2026-05-17 22:20:49 -04:00
Zac
b68f0c6aba feat(editor): integrate openreel-video as services/editor with MAM hooks
Vendored Augani/openreel-video (MIT) into services/editor and wired it to the MAM. Editor runs as its own container on port 47435. Library assets pull in via ?asset=<uuid>; render exports route back via POST /api/v1/upload/simple. Sidebar Editor link on every page; Edit button on every preview modal. See services/editor/INTEGRATION.md for the patch map.
2026-05-17 21:44:37 -04:00
Zac
562881f0db fix(jobs): stall detection + manual kill button so 5h-stuck actives can't happen
A thumbnail job from earlier stayed 'active' for 6+ hours: worker was restarted at 70% progress, BullMQ left it in the active set, and there was no stall reaper because the worker was created with only the default options.

Worker now passes stalledInterval: 30000, lockDuration: 60000, lockRenewTime: 15000, maxStalledCount: 1 to the Worker constructor. If a run dies, BullMQ reclaims the job back to waiting within 30s and a 'stalled' event is logged. Otherwise the lock is renewed mid-job.

Jobs UI gains a 'Kill' button per row next to Details. Calls DELETE /api/v1/jobs/:id which already removes the job from Redis. Use it on any row that looks stuck.
2026-05-17 19:10:19 -04:00
Zac
e441176961 feat(design): broadcast ops console redesign sweep
Confirmed shape brief: ops-console direction, balanced density, blue committed accent, readability emphasized.

Design system: surface scale to 5 steps tinted toward hue 266; text contrast lifted (secondary 62->72%, tertiary 44->52, added text-disabled); borders gained faint variant; status tokens renamed semantically (signal-good/warn/bad/idle); typography upgraded (Inter 400/500/600, JetBrains Mono for numerics, new 2xl/3xl/tc scale steps).

New components: tc-display timecode classes with blue glow; tally-word with good/warn/bad/rec variants; signal-strip flutter bar with modifiers; chip dense monospaced pill; manifest table styles.

Topbar status strip: new js/topbar-strip.js auto-injects a 28px strip on every page with wall clock, page name, live API latency, and system-pulse dot.

Per-page: recorders gain live signal-strip above fps line; capture timecode bumped 38->64px with blue glow; ingest drop zone slimmed 200->88px side-by-side layout so manifest gets the real estate.
2026-05-17 19:05:22 -04:00
Zac
bab24e156a feat(recorders): probe sources + reflect real signal in main status
Two things that together stop bogus URLs from masquerading as a recording:

PROBE BUTTON in the New Recorder panel. Before you commit to record, hit Probe Source - the capture container runs ffprobe with a 10s timeout against the URL and returns the parsed streams. UI shows green Signal Detected with codec/resolution/fps/audio, or red No Signal Detected with the actual ffprobe error message. For SDI it lists DeckLink devices. Listener-mode sources cannot be probed standalone (would block waiting for a publisher) and the UI says so.

MAIN STATUS LABEL ON THE RECORDING CARD now mirrors the live signal instead of hardcoding Recording. So a recorder pointed at a dead URL goes Connecting... -> Connection error (red) instead of looking like everything is fine. When frames actually start arriving the label flips to Recording (blue) and the dot turns blue. If a previously-good stream drops the label switches to Signal lost (red).

API:
* capture: POST /capture/probe runs ffprobe and returns { ok, streams, format, error? }
* mam-api: POST /api/v1/recorders/probe proxies through to the capture sidecar with a 15s outer timeout
2026-05-17 18:39:21 -04:00
Zac
f2b8d5dc4b feat(splash): transparent PNG so the subject composites cleanly
The source image had a black border baked in. Knocked out the dark pixels into an alpha channel so the figure now floats on whatever surface is behind it — the dark gradient on the splash, the panel surface on the loading indicator, anywhere.

Pipeline: source -> resize 1200w -> python/PIL alpha-from-luminance with soft 22-55 luminance ramp -> 8-bit RGBA PNG (267KB).
2026-05-17 18:39:21 -04:00
Zac
349bc5a41d feat: multi-select + bulk move/copy/delete, brand blue, hardhat loader
* Library cards now show a checkbox on hover (and persistent when selected). Click checkbox = toggle, shift-click = range. Plain click on a card with an active selection extends/shrinks the selection instead of opening preview. Floating pill at the bottom shows count + Move / Copy / Delete / Clear. Move + Copy open a tiny bin picker (current project, default to current bin).

* mam-api/routes/assets.js: PATCH /:id now also accepts bin_id (null = move out of bin). New POST /:id/copy makes a reference-copy of the asset row (same S3 keys, new id) into the target bin/project.

* api.js: moveAsset(id, binId) and copyAsset(id, {binId, projectId}) helpers.

* All accent tokens swapped from the amber oklch(76% 0.178 52) to the Wild Dragon signature blue oklch(55% 0.20 266) = #1f3ad0 ish. Login splash + first-load splash + signal-receiving + button primary all picked it up automatically through common.css.

* Loading indicator across the app uses the AMPP Safe hardhat photo gently pulsing with a tiny blue dot underneath. .ampp-loading component lives in common.css with --sm / --xs / --inline variants. Replaces the plain "Loading assets…" empty state in index.html.
2026-05-17 14:48:34 -04:00
Zac
f99f07e0e7 feat: AMPP Safe splash on login + first-visit overlay
Adds the BMG-branded "AMPP Safe" hardhat photo as the visual identity for the auth + first-load surfaces.

* services/web-ui/public/img/ampp-safe.jpg (52 KB, 1200w optimized JPEG)
* services/web-ui/public/login.html: full redesign as a two-column hero + sign-in panel. Hero shows the hardhat photo full-bleed with a subtle AMPP Safe pill badge and broadcast-safe caption. Login + first-run admin setup forms unchanged functionally.
* services/web-ui/public/index.html: brief first-visit splash overlay (~1.4s) using the same image. Dismisses to the library and uses sessionStorage so it only shows once per session.
2026-05-17 13:10:47 -04:00
Zac
72545126c4 fix: delete asset actually deletes
Trash icon in the library was firing PATCH /assets/:id with {status:"deleted"}. The PATCH route only accepts display_name/tags/notes so it returned "No fields to update" and the asset stayed put.

* api.js: add deleteAsset(id, {hard}) helper hitting the real DELETE route.
* index.html: deleteAssetPrompt now calls deleteAsset (soft archive). Confirm dialog reworded to match.
* mam-api/routes/assets.js: list endpoint hides status=archived by default. Pass ?include_archived=true to see them in a future restore-from-trash view. Filtering by ?status=archived still works for power users.
* All HTML: bump api.js cache-buster v=4 -> v=5 so the new helper is fetched.
2026-05-17 12:55:55 -04:00
Zac
ea28c5189d feat: in-library asset preview + Premiere plugin installer
Click any asset card to open a modal with the H.264 proxy playing inline (or audio/image, per media_type). Esc or click outside closes. Sidebar shows status/codec/resolution/fps/duration/size/created plus tags and notes.

Plugin install side: added install-windows.ps1 that copies the CEP panel to %APPDATA%\Adobe\CEP\extensions, flips PlayerDebugMode=1 across the CSXS.8-13 hives, and prints the next steps. Plugin already wired against the current API.

* services/web-ui/public/js/preview.js: standalone IIFE that lazy-injects the modal markup + CSS on first use. Renders <video controls> (or <audio>, <img>) sourced from /api/v1/assets/:id/stream, with sidebar from /api/v1/assets/:id. Falls back to a clear empty state when proxy is still processing.
* services/web-ui/public/index.html: loads preview.js, wires asset-card click to window.openAssetPreview(asset.id), guards against delete-button clicks bubbling.
* services/premiere-plugin/install-windows.ps1: one-shot Windows installer for the CEP extension.
2026-05-17 08:55:14 -04:00
Zac
3ea896c368 fix(web-ui): bust JS cache so api.js fix actually reaches the browser
The api.js library-list fix from the previous commit never reached the browser because nginx served all .js with `Cache-Control: public, immutable; max-age=31536000`. The HTML referenced api.js with no version query, so the browser kept its year-cached buggy copy.

* nginx.conf: drop .js from the immutable long-cache block, add a no-cache must-revalidate block so future redeploys are picked up immediately.
* All HTML files: tag api.js refs with ?v=4 so already-running browsers fetch the new version on next page load.
2026-05-17 08:31:00 -04:00
Zac
ac1878452f fix: library + caller-only recorders + live signal indicator
Three problems blocked the end-to-end flow:

1) Library always rendered empty because /assets returns {assets,total} but
   index.html (and capture.html) assumed r.data was an array. Fixed in
   api.js by unwrapping r.data.assets centrally; total is kept on r.total.

2) SRT/RTMP caller mode pulled audio only. ffmpeg opened the network input
   before the H264 SPS arrived, marked the video stream as pix_fmt=none,
   and silently dropped it from the stream map. Added -probesize 32M
   -analyzeduration 10M -fflags +genpts and explicit -map 0✌️0?/0🅰️0? so
   each track survives independently of when it appears.

3) Hitting Record gave no feedback about whether a stream was actually
   arriving. capture-manager now parses ffmpeg progress lines (frame=...
   fps=...) and tracks framesReceived, currentFps, lastFrameAt, lastError.
   getStatus() returns a derived signal enum (connecting | receiving |
   lost | error | stopped). The recorder controller gives each spawned
   container a stable network alias `recorder-<id>` and the GET
   /recorders/:id/status endpoint proxies the live capture status through.
   recorders.html polls that every 2s and renders the badge under each
   active card with the running frame/fps counter or the ffmpeg error.

Also:
* recorders.html: dropped the listener-mode UI entirely. All new recorders
  are caller-mode (pull). The MAM is no longer offered as an RTMP/SRT
  server. Legacy listener records still render but read-only.
2026-05-17 07:39:58 -04:00
692 changed files with 221434 additions and 1436 deletions

4
.gitignore vendored
View file

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

View file

@ -34,6 +34,7 @@ services:
- "${PORT_MAM_API:-7432}:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/NVME/MAM/wild-dragon-live:/live
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
@ -64,6 +65,8 @@ services:
S3_SECRET_KEY: ${S3_SECRET_KEY}
S3_REGION: ${S3_REGION:-us-east-1}
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
networks:
- wild-dragon
@ -87,6 +90,18 @@ services:
build: ./services/web-ui
ports:
- "${PORT_WEB_UI:-7434}:80"
volumes:
- /mnt/NVME/MAM/wild-dragon-live:/live
networks:
- wild-dragon
editor:
build: ./services/editor
depends_on:
- mam-api
ports:
- "${PORT_EDITOR:-47435}:80"
networks:
- wild-dragon

View file

@ -11,6 +11,11 @@ class CaptureManager {
sessionId: null,
processes: {},
currentSession: {},
// Live signal metrics derived from ffmpeg stderr
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
};
}
@ -32,7 +37,7 @@ class CaptureManager {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
}
return { inputArgs: ['-i', url], isNetwork: true };
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
}
if (sourceType === 'rtmp') {
@ -40,11 +45,11 @@ class CaptureManager {
const port = listenPort || 1935;
const key = streamKey || 'stream';
return {
inputArgs: ['-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
isNetwork: true,
};
}
return { inputArgs: ['-i', sourceUrl], isNetwork: true };
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// Default: SDI via DeckLink
@ -67,6 +72,7 @@ class CaptureManager {
* @returns {Object} Session info
*/
async start({
assetId,
projectId,
binId,
clipName,
@ -77,6 +83,7 @@ class CaptureManager {
listenPort,
streamKey,
}) {
this._assetIdForHls = assetId || null;
if (this.state.recording) {
throw new Error('Capture already in progress');
}
@ -105,6 +112,8 @@ class CaptureManager {
// ProRes hires — fragmented moov for pipe-safe output on network sources
const hiresCodecArgs = isNetwork
? [
'-map', '0:v:0?',
'-map', '0:a:0?',
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
@ -131,9 +140,49 @@ class CaptureManager {
const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload };
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
fs.mkdirSync(hlsDir, { recursive: true });
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
hlsDir + '/index.m3u8',
];
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => {
console.error(`[HIRES] ${data}`);
const text = data.toString();
console.error(`[HIRES] ${text}`);
// Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString();
}
// Surface fatal-looking lines for the status endpoint
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
});
// SDI only: spawn a second FFmpeg process for the proxy.
@ -166,6 +215,10 @@ class CaptureManager {
this.state.recording = true;
this.state.sessionId = sessionId;
this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.currentSession = {
sessionId,
projectId,
@ -200,9 +253,8 @@ class CaptureManager {
if (processes.hires) {
processes.hires.kill('SIGINT');
}
if (processes.proxy) {
processes.proxy.kill('SIGINT');
}
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
try {
// Wait for all in-flight S3 uploads to complete
@ -254,6 +306,14 @@ class CaptureManager {
const now = new Date();
const duration = Math.round((now - startTime) / 1000);
const lastFrameAt = this.state.lastFrameAt;
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
let signal = 'connecting';
if (this.state.framesReceived > 0) {
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
} else if (this.state.lastError) {
signal = 'error';
}
return {
recording: true,
sessionId: this.state.sessionId,
@ -264,6 +324,12 @@ class CaptureManager {
binId: this.state.currentSession.binId,
duration,
startedAt: this.state.currentSession.startedAt,
signal,
framesReceived: this.state.framesReceived,
currentFps: this.state.currentFps,
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
};
}

View file

@ -2,11 +2,13 @@ import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import captureRoutes from './routes/capture.js';
import captureManager from './capture-manager.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
// Middleware
app.use(cors());
@ -21,6 +23,107 @@ app.get('/health', (req, res) => {
app.use('/capture', captureRoutes);
// Start server
app.listen(PORT, () => {
const server = app.listen(PORT, () => {
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
bootstrapAutoStart();
});
async function bootstrapAutoStart() {
const recorderId = process.env.RECORDER_ID;
const sourceType = process.env.SOURCE_TYPE;
if (!recorderId || !sourceType) {
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
return;
}
const projectId = process.env.PROJECT_ID;
const clipName = process.env.CLIP_NAME;
if (!projectId || !clipName) {
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
return;
}
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
const listenPort = process.env.LISTEN_PORT
? parseInt(process.env.LISTEN_PORT, 10)
: undefined;
const streamKey = process.env.STREAM_KEY || undefined;
const sourceUrl = process.env.SOURCE_URL || undefined;
if (sourceType === 'sdi') {
console.warn('[bootstrap] SDI auto-start not supported');
return;
}
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
try {
const session = await captureManager.start({
assetId: process.env.ASSET_ID || null,
projectId,
binId: process.env.BIN_ID || null,
clipName,
sourceType,
sourceUrl,
listen,
listenPort,
streamKey,
});
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
} catch (err) {
console.error('[bootstrap] failed to start capture:', err);
}
}
let shuttingDown = false;
async function gracefulShutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received`);
const status = captureManager.getStatus();
if (status.recording) {
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
try {
const completed = await captureManager.stop(status.sessionId);
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
try {
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: completed.projectId,
binId: completed.binId,
clipName: completed.clipName,
sourceType: completed.sourceType,
hiresKey: completed.hiresKey,
proxyKey: completed.proxyKey,
needsProxy: completed.proxyKey === null,
duration: completed.duration,
capturedAt: completed.startedAt,
}),
});
if (!res.ok) {
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
} else {
console.log('[shutdown] asset registered with mam-api');
}
} catch (mamErr) {
console.error('[shutdown] failed to register asset:', mamErr.message);
}
} catch (err) {
console.error('[shutdown] error during stop:', err);
}
}
server.close(() => {
console.log('[shutdown] http server closed - exiting');
process.exit(0);
});
setTimeout(() => process.exit(0), 5000).unref();
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View file

@ -1,7 +1,78 @@
import express from 'express';
import { execSync } from 'child_process';
import { execSync, spawn } from 'child_process';
import captureManager from '../capture-manager.js';
import dgram from 'dgram';
import net from 'net';
function parseUrl(u) {
try {
const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i);
if (!m) return null;
return { host: m[1], port: parseInt(m[2] || '0', 10) };
} catch (_) { return null; }
}
async function checkReachable(host, port, sourceType) {
if (!port) return { ok: true };
if (sourceType === 'srt') return await udpSendProbe(host, port);
if (sourceType === 'rtmp') return await tcpConnectProbe(host, port);
return { ok: true };
}
function udpSendProbe(host, port) {
return new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
let done = false;
const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); };
sock.on('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg });
} else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) {
finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg });
} else {
finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg });
}
});
sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {});
setTimeout(() => finish({ ok: true }), 1500);
});
}
function tcpConnectProbe(host, port) {
return new Promise((resolve) => {
const sock = new net.Socket();
let done = false;
const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); };
sock.setTimeout(2500);
sock.once('connect', () => finish({ ok: true }));
sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' }));
sock.once('error', (err) => {
const msg = String(err && err.message || err);
if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg });
else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg });
else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg });
});
sock.connect(port, host);
});
}
function classifyProbeError(raw, sourceType) {
const r = (raw || '').toLowerCase();
if (sourceType === 'srt') {
if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) {
return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).';
}
}
if (sourceType === 'rtmp') {
if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.';
if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.';
}
return raw;
}
const router = express.Router();
const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000';
@ -60,6 +131,81 @@ router.get('/status', (req, res) => {
res.status(500).json({ error: 'Failed to get status' });
}
});
router.post('/probe', async (req, res) => {
try {
const { source_type = 'sdi', source_url, listen = false } = req.body || {};
if (source_type === 'sdi') {
try {
const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 });
const devices = [];
for (const line of raw.split('\n')) {
const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/);
if (m) devices.push(m[1]);
}
return res.json({ ok: true, source_type, devices });
} catch (err) {
const out = (err.stderr || err.stdout || err.toString()).toString();
return res.json({ ok: false, source_type, error: out.slice(0, 800) });
}
}
if (listen) {
return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' });
}
if (!source_url) return res.status(400).json({ error: 'source_url is required' });
// Pre-flight: parse host:port and check L3/L4 reachability so we can give
// an actionable error instead of the opaque libsrt "Input/output error".
const parsed = parseUrl(source_url);
if (!parsed) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' });
}
const reach = await checkReachable(parsed.host, parsed.port, source_type);
if (!reach.ok) {
return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic });
}
let url = source_url;
if (source_type === 'srt' && !/mode=/.test(url)) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json'];
const ff = spawn('ffprobe', args);
let stdout = '', stderr = '';
ff.stdout.on('data', (c) => { stdout += c; });
ff.stderr.on('data', (c) => { stderr += c; });
const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000);
ff.on('close', (code) => {
clearTimeout(killer);
if (code !== 0) {
const rawErr = (stderr || 'ffprobe failed').slice(0, 800);
const friendly = classifyProbeError(rawErr, source_type);
return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr });
}
try {
const parsed = JSON.parse(stdout);
const streams = (parsed.streams || []).map(s => ({
index: s.index, codec_type: s.codec_type, codec_name: s.codec_name,
width: s.width, height: s.height, pix_fmt: s.pix_fmt,
r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate,
sample_rate: s.sample_rate, channels: s.channels,
channel_layout: s.channel_layout, bit_rate: s.bit_rate,
}));
return res.json({ ok: true, source_type, source_url,
format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate },
streams });
} catch (err) {
return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message });
}
});
} catch (error) {
console.error('Probe error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /start

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

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

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

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

View file

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

View file

@ -0,0 +1,387 @@
# Contributing to OpenReel
Thank you for your interest in contributing to OpenReel! This document provides guidelines and instructions for contributing.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Project Structure](#project-structure)
- [Coding Standards](#coding-standards)
- [Making Changes](#making-changes)
- [Testing](#testing)
- [Submitting Changes](#submitting-changes)
## Code of Conduct
Be respectful, constructive, and professional. We're building something great together!
## Getting Started
### Prerequisites
- Node.js 18 or higher
- pnpm (recommended) or npm
- Git
- Modern browser with WebCodecs support (Chrome 94+, Edge 94+)
### Development Setup
```bash
# 1. Fork and clone the repository
git clone https://github.com/Augani/openreel-video.git
cd openreel-video
# 2. Install dependencies
pnpm install
# 3. Start development server
pnpm dev
# 4. Open browser to http://localhost:5173
```
## Project Structure
```
openreel/
├── apps/
│ └── web/ # Main web application
│ ├── public/ # Static assets
│ └── src/
│ ├── components/ # React components
│ ├── stores/ # State management (Zustand)
│ ├── bridges/ # Core engine bridges
│ └── services/ # Business logic
├── packages/
│ └── core/ # Shared core logic
│ ├── src/
│ │ ├── actions/ # Action system
│ │ ├── video/ # Video processing
│ │ ├── audio/ # Audio processing
│ │ ├── graphics/ # Graphics & SVG
│ │ ├── text/ # Text & titles
│ │ └── export/ # Export engine
│ └── types/ # TypeScript types
```
## Coding Standards
### TypeScript
- **Strict mode**: Always use TypeScript strict mode
- **Types**: Prefer interfaces over types for object shapes
- **No `any`**: Avoid `any` - use `unknown` or proper types
- **Naming**:
- Components: `PascalCase` (e.g., `Timeline`, `Preview`)
- Functions: `camelCase` (e.g., `handleClick`, `processVideo`)
- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_DURATION`)
- Files: `kebab-case.tsx` or `PascalCase.tsx` for components
### Code Style
```typescript
// ✅ Good
interface VideoClip {
id: string;
duration: number;
startTime: number;
}
function processClip(clip: VideoClip): ProcessedClip {
if (!clip.id) {
throw new Error('Clip ID is required');
}
return {
...clip,
processed: true,
};
}
// ❌ Avoid
function processClip(clip: any) {
console.log('Processing...'); // Remove debug logs
const result = clip; // Unclear what's happening
return result;
}
```
### React Components
```typescript
// ✅ Good
interface TimelineProps {
tracks: Track[];
onClipSelect: (clipId: string) => void;
}
export const Timeline: React.FC<TimelineProps> = ({ tracks, onClipSelect }) => {
const handleClick = useCallback((id: string) => {
onClipSelect(id);
}, [onClipSelect]);
return (
<div className="timeline">
{tracks.map(track => (
<Track key={track.id} track={track} onClick={handleClick} />
))}
</div>
);
};
```
### Comments
- **Do**: Comment complex algorithms and business logic
- **Don't**: Comment obvious code
- **Do**: Add JSDoc for public APIs
- **Don't**: Leave TODO comments without issues
```typescript
// ✅ Good - Explains WHY
// Use binary search for O(log n) performance on large timelines
const clipIndex = binarySearch(clips, targetTime);
// ❌ Bad - States the obvious
// Loop through clips
for (const clip of clips) { }
// ✅ Good - Public API documentation
/**
* Applies a filter to a video clip
* @param clipId - The clip identifier
* @param filter - Filter configuration
* @returns Updated clip with filter applied
*/
export function applyFilter(clipId: string, filter: Filter): Clip {
// ...
}
```
## Making Changes
### 1. Create a Branch
```bash
# Feature branch
git checkout -b feat/add-transition-effects
# Bug fix branch
git checkout -b fix/timeline-scroll-bug
# Documentation
git checkout -b docs/update-contributing-guide
```
### 2. Make Your Changes
- Write clean, self-documenting code
- Follow the existing code style
- Keep commits focused and atomic
- Write meaningful commit messages
### 3. Commit Messages
Follow conventional commits:
```
feat: add crossfade transition effect
fix: resolve timeline scrubbing lag
docs: update API documentation
refactor: simplify video processing pipeline
test: add tests for audio mixer
perf: optimize waveform rendering
```
### 4. Keep Your Branch Updated
```bash
git fetch origin
git rebase origin/main
```
## Testing
### Running Tests
```bash
# Run all tests (watch mode)
pnpm test
# Run tests once (CI mode)
pnpm test:run
# Type checking
pnpm typecheck
# Linting
pnpm lint
```
### Writing Tests
```typescript
import { describe, it, expect } from 'vitest';
import { processClip } from './clip-processor';
describe('processClip', () => {
it('should process a valid clip', () => {
const clip = { id: '123', duration: 10, startTime: 0 };
const result = processClip(clip);
expect(result.processed).toBe(true);
expect(result.id).toBe('123');
});
it('should throw error for invalid clip', () => {
const clip = { id: '', duration: 10, startTime: 0 };
expect(() => processClip(clip)).toThrow('Clip ID is required');
});
});
```
## Submitting Changes
### 1. Push Your Branch
```bash
git push origin feat/your-feature-name
```
### 2. Create a Pull Request
1. Go to GitHub and create a pull request
2. Fill out the PR template:
- **Description**: What does this PR do?
- **Motivation**: Why is this change needed?
- **Testing**: How was this tested?
- **Screenshots**: For UI changes
- **Breaking Changes**: Any breaking changes?
### 3. PR Template
```markdown
## Description
Brief description of changes
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Testing
- [ ] Tested locally
- [ ] Added/updated tests
- [ ] All tests passing
## Screenshots (if applicable)
[Add screenshots for UI changes]
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex code
- [ ] Documentation updated
- [ ] No console.log or debug code left
- [ ] Tests pass
```
### 4. Code Review Process
- Respond to feedback promptly
- Make requested changes
- Push updates to the same branch
- Re-request review when ready
## Areas to Contribute
### 🐛 Bug Fixes
- Check [Issues](https://github.com/Augani/openreel-video/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
- Reproduce the bug
- Write a failing test
- Fix the bug
- Verify the test passes
### ✨ New Features
- Discuss in [Discussions](https://github.com/Augani/openreel-video/discussions) first
- Get approval before large changes
- Break into smaller PRs if possible
- Update documentation
### 📖 Documentation
- Fix typos and errors
- Add examples
- Improve clarity
- Add tutorials
### 🎨 Effects & Presets
- Create new video effects
- Add transition effects
- Build color grading presets
- Contribute templates
### 🧪 Testing
- Add missing tests
- Improve test coverage
- Add integration tests
- Performance testing
### 🌍 Translation
- Add new language support
- Improve existing translations
- Fix translation errors
## Development Tips
### Hot Reload
Changes to React components hot reload automatically. For core engine changes, you may need to refresh.
### Debugging
```typescript
// Use browser DevTools
// Set breakpoints in TypeScript source
// Check Network tab for media loading
// Use Performance profiler for optimization
```
### Performance
- Profile before optimizing
- Use Web Workers for heavy processing
- Leverage WebCodecs API for video
- Cache expensive computations
- Use useMemo/useCallback appropriately
### Common Issues
**Issue**: Video won't play
- Check browser support for WebCodecs
- Verify codec support
- Check browser console for errors
**Issue**: Build fails
- Clear node_modules and reinstall
- Check Node.js version (18+)
- Verify pnpm version
**Issue**: Tests fail
- Try running `pnpm test:run` for a single run
- Check for console errors
- Verify test environment setup
- Run `pnpm typecheck` to check for type errors
## Questions?
- **Discord**: [Join our Discord](https://discord.gg/openreeel)
- **Discussions**: [GitHub Discussions](https://github.com/Augani/openreel-video/discussions)
- **Email**: contribute@openreeel.video
## Recognition
Contributors are recognized in:
- README.md contributors section
- GitHub contributors page
- Release notes for significant contributions
Thank you for contributing to OpenReel! 🎬

View file

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

View file

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

21
services/editor/LICENSE Normal file
View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 389 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,117 @@
export type PhotoFilterPreset =
| 'warming-85'
| 'warming-81'
| 'warming-lba'
| 'cooling-80'
| 'cooling-82'
| 'cooling-lbb'
| 'red'
| 'orange'
| 'yellow'
| 'green'
| 'cyan'
| 'blue'
| 'violet'
| 'magenta'
| 'sepia'
| 'deep-red'
| 'deep-blue'
| 'deep-emerald'
| 'deep-yellow'
| 'underwater'
| 'custom';
export interface PhotoFilterSettings {
filter: PhotoFilterPreset;
color: string;
density: number;
preserveLuminosity: boolean;
}
export const DEFAULT_PHOTO_FILTER: PhotoFilterSettings = {
filter: 'warming-85',
color: '#ec8a00',
density: 25,
preserveLuminosity: true,
};
export const PHOTO_FILTER_COLORS: Record<PhotoFilterPreset, string> = {
'warming-85': '#ec8a00',
'warming-81': '#ebb113',
'warming-lba': '#fa9600',
'cooling-80': '#006dff',
'cooling-82': '#00b5ff',
'cooling-lbb': '#005fcc',
red: '#ea1a1a',
orange: '#f28e00',
yellow: '#f9d71c',
green: '#1ab800',
cyan: '#00e5e5',
blue: '#0000ff',
violet: '#8000ff',
magenta: '#ea00ea',
sepia: '#ac7a33',
'deep-red': '#a10000',
'deep-blue': '#000066',
'deep-emerald': '#003d00',
'deep-yellow': '#998c00',
underwater: '#00c2b0',
custom: '#ffffff',
};
function parseColor(color: string): { r: number; g: number; b: number } {
const match = color.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (match) {
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16),
};
}
return { r: 255, g: 255, b: 255 };
}
function getLuminance(r: number, g: number, b: number): number {
return r * 0.299 + g * 0.587 + b * 0.114;
}
export function applyPhotoFilter(imageData: ImageData, settings: PhotoFilterSettings): ImageData {
const { width, height, data } = imageData;
const resultData = new Uint8ClampedArray(data.length);
const filterColor = settings.filter === 'custom'
? parseColor(settings.color)
: parseColor(PHOTO_FILTER_COLORS[settings.filter]);
const density = settings.density / 100;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const originalLuminance = getLuminance(r, g, b);
let newR = r + (filterColor.r - r) * density;
let newG = g + (filterColor.g - g) * density;
let newB = b + (filterColor.b - b) * density;
if (settings.preserveLuminosity) {
const newLuminance = getLuminance(newR, newG, newB);
if (newLuminance > 0) {
const ratio = originalLuminance / newLuminance;
newR *= ratio;
newG *= ratio;
newB *= ratio;
}
}
resultData[i] = Math.max(0, Math.min(255, newR));
resultData[i + 1] = Math.max(0, Math.min(255, newG));
resultData[i + 2] = Math.max(0, Math.min(255, newB));
resultData[i + 3] = a;
}
return new ImageData(resultData, width, height);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -0,0 +1,206 @@
import { useEffect, useRef } from 'react';
import { useUIStore } from '../../../stores/ui-store';
import { useProjectStore } from '../../../stores/project-store';
const RULER_SIZE = 20;
const RULER_BG = '#1f1f23';
const RULER_TEXT = '#71717a';
const RULER_TICK = '#3f3f46';
const RULER_HIGHLIGHT = '#3b82f6';
interface RulersProps {
containerWidth: number;
containerHeight: number;
}
export function Rulers({ containerWidth, containerHeight }: RulersProps) {
const horizontalRef = useRef<HTMLCanvasElement>(null);
const verticalRef = useRef<HTMLCanvasElement>(null);
const cornerRef = useRef<HTMLDivElement>(null);
const { zoom, panX, panY, showRulers } = useUIStore();
const { project, selectedArtboardId } = useProjectStore();
const artboard = project?.artboards.find((a) => a.id === selectedArtboardId);
useEffect(() => {
if (!showRulers || !artboard) return;
if (containerWidth <= RULER_SIZE || containerHeight <= RULER_SIZE) return;
const hCanvas = horizontalRef.current;
const vCanvas = verticalRef.current;
if (!hCanvas || !vCanvas) return;
const hCtx = hCanvas.getContext('2d');
const vCtx = vCanvas.getContext('2d');
if (!hCtx || !vCtx) return;
hCanvas.width = containerWidth - RULER_SIZE;
hCanvas.height = RULER_SIZE;
vCanvas.width = RULER_SIZE;
vCanvas.height = containerHeight - RULER_SIZE;
const centerX = containerWidth / 2 + panX;
const centerY = containerHeight / 2 + panY;
const artboardX = centerX - (artboard.size.width * zoom) / 2;
const artboardY = centerY - (artboard.size.height * zoom) / 2;
renderHorizontalRuler(hCtx, containerWidth, artboardX, artboard.size.width, zoom);
renderVerticalRuler(vCtx, containerHeight, artboardY, artboard.size.height, zoom);
}, [containerWidth, containerHeight, zoom, panX, panY, showRulers, artboard]);
if (!showRulers) return null;
return (
<>
<div
ref={cornerRef}
className="absolute top-0 left-0 z-20"
style={{
width: RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
borderRight: `1px solid ${RULER_TICK}`,
borderBottom: `1px solid ${RULER_TICK}`,
}}
/>
<canvas
ref={horizontalRef}
className="absolute top-0 z-10"
style={{
left: RULER_SIZE,
width: containerWidth - RULER_SIZE,
height: RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
<canvas
ref={verticalRef}
className="absolute left-0 z-10"
style={{
top: RULER_SIZE,
width: RULER_SIZE,
height: containerHeight - RULER_SIZE,
backgroundColor: RULER_BG,
}}
/>
</>
);
}
function getTickInterval(zoom: number): { major: number; minor: number } {
const baseUnit = 100;
const scaledUnit = baseUnit / zoom;
if (scaledUnit < 50) return { major: 50, minor: 10 };
if (scaledUnit < 100) return { major: 100, minor: 20 };
if (scaledUnit < 200) return { major: 100, minor: 25 };
if (scaledUnit < 500) return { major: 200, minor: 50 };
if (scaledUnit < 1000) return { major: 500, minor: 100 };
return { major: 1000, minor: 200 };
}
function renderHorizontalRuler(
ctx: CanvasRenderingContext2D,
width: number,
artboardX: number,
artboardWidth: number,
zoom: number
) {
ctx.fillStyle = RULER_BG;
ctx.fillRect(0, 0, width, RULER_SIZE);
const { major, minor } = getTickInterval(zoom);
ctx.strokeStyle = RULER_TICK;
ctx.fillStyle = RULER_TEXT;
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startX = -Math.ceil(artboardX / (minor * zoom)) * minor;
const endX = artboardWidth + Math.ceil((width - artboardX - artboardWidth * zoom) / (minor * zoom)) * minor;
for (let i = startX; i <= endX; i += minor) {
const screenX = artboardX + i * zoom - RULER_SIZE;
if (screenX < 0 || screenX > width) continue;
const isMajor = i % major === 0;
const tickHeight = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(screenX, RULER_SIZE);
ctx.lineTo(screenX, RULER_SIZE - tickHeight);
ctx.stroke();
if (isMajor) {
ctx.fillText(String(i), screenX, 2);
}
}
const artboardStart = artboardX - RULER_SIZE;
const artboardEnd = artboardX + artboardWidth * zoom - RULER_SIZE;
ctx.strokeStyle = RULER_HIGHLIGHT;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(Math.max(0, artboardStart), RULER_SIZE - 1);
ctx.lineTo(Math.min(width, artboardEnd), RULER_SIZE - 1);
ctx.stroke();
ctx.lineWidth = 1;
}
function renderVerticalRuler(
ctx: CanvasRenderingContext2D,
height: number,
artboardY: number,
artboardHeight: number,
zoom: number
) {
ctx.fillStyle = RULER_BG;
ctx.fillRect(0, 0, RULER_SIZE, height);
const { major, minor } = getTickInterval(zoom);
ctx.strokeStyle = RULER_TICK;
ctx.fillStyle = RULER_TEXT;
ctx.font = '9px Inter, system-ui, sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
const startY = -Math.ceil(artboardY / (minor * zoom)) * minor;
const endY = artboardHeight + Math.ceil((height - artboardY - artboardHeight * zoom) / (minor * zoom)) * minor;
for (let i = startY; i <= endY; i += minor) {
const screenY = artboardY + i * zoom - RULER_SIZE;
if (screenY < 0 || screenY > height) continue;
const isMajor = i % major === 0;
const tickWidth = isMajor ? 12 : 6;
ctx.beginPath();
ctx.moveTo(RULER_SIZE, screenY);
ctx.lineTo(RULER_SIZE - tickWidth, screenY);
ctx.stroke();
if (isMajor) {
ctx.save();
ctx.translate(10, screenY);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = 'center';
ctx.fillText(String(i), 0, 0);
ctx.restore();
}
}
const artboardStart = artboardY - RULER_SIZE;
const artboardEnd = artboardY + artboardHeight * zoom - RULER_SIZE;
ctx.strokeStyle = RULER_HIGHLIGHT;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(RULER_SIZE - 1, Math.max(0, artboardStart));
ctx.lineTo(RULER_SIZE - 1, Math.min(height, artboardEnd));
ctx.stroke();
ctx.lineWidth = 1;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,308 @@
import { useCallback, useMemo } from 'react';
import { useProjectStore } from '../../../stores/project-store';
import { useUIStore, CropAspectRatio } from '../../../stores/ui-store';
import type { ImageLayer } from '../../../types/project';
import { Crop, Check, X, RotateCcw, Lock, Unlock } from 'lucide-react';
const imageCache = new Map<string, HTMLImageElement>();
function getCachedImage(src: string): HTMLImageElement | null {
if (!src) return null;
if (imageCache.has(src)) return imageCache.get(src)!;
const img = new Image();
img.src = src;
imageCache.set(src, img);
return img;
}
interface Props {
layer: ImageLayer;
}
const ASPECT_RATIOS: { value: CropAspectRatio; label: string; ratio?: number }[] = [
{ value: 'free', label: 'Free' },
{ value: 'original', label: 'Original' },
{ value: '1:1', label: '1:1', ratio: 1 },
{ value: '4:3', label: '4:3', ratio: 4 / 3 },
{ value: '3:4', label: '3:4', ratio: 3 / 4 },
{ value: '16:9', label: '16:9', ratio: 16 / 9 },
{ value: '9:16', label: '9:16', ratio: 9 / 16 },
{ value: '3:2', label: '3:2', ratio: 3 / 2 },
{ value: '2:3', label: '2:3', ratio: 2 / 3 },
];
export function CropSection({ layer }: Props) {
const { updateLayer, project } = useProjectStore();
const { crop, startCrop, cancelCrop, applyCrop, setCropAspectRatio, updateCropRect, setCropLockAspect } = useUIStore();
const lockAspect = crop.lockAspect;
const setLockAspect = setCropLockAspect;
const isCropping = crop.isActive && crop.layerId === layer.id;
const imageDimensions = useMemo(() => {
if (!project) return null;
const asset = project.assets[layer.sourceId];
if (!asset) return null;
const src = asset.blobUrl ?? asset.dataUrl;
if (!src) return null;
const img = getCachedImage(src);
if (img && img.complete && img.naturalWidth > 0) {
return { width: img.naturalWidth, height: img.naturalHeight };
}
return asset.width && asset.height ? { width: asset.width, height: asset.height } : null;
}, [project, layer.sourceId]);
const handleStartCrop = useCallback(() => {
const initialRect = layer.cropRect ?? {
x: 0,
y: 0,
width: layer.transform.width,
height: layer.transform.height,
};
startCrop(layer.id, initialRect);
}, [layer, startCrop]);
const handleApplyCrop = useCallback(() => {
const result = applyCrop();
if (result && result.cropRect) {
const existingCropRect = layer.cropRect;
let finalCropRect: { x: number; y: number; width: number; height: number };
if (existingCropRect) {
const scaleX = existingCropRect.width / layer.transform.width;
const scaleY = existingCropRect.height / layer.transform.height;
finalCropRect = {
x: existingCropRect.x + result.cropRect.x * scaleX,
y: existingCropRect.y + result.cropRect.y * scaleY,
width: result.cropRect.width * scaleX,
height: result.cropRect.height * scaleY,
};
} else if (imageDimensions) {
const scaleX = imageDimensions.width / layer.transform.width;
const scaleY = imageDimensions.height / layer.transform.height;
finalCropRect = {
x: result.cropRect.x * scaleX,
y: result.cropRect.y * scaleY,
width: result.cropRect.width * scaleX,
height: result.cropRect.height * scaleY,
};
} else {
finalCropRect = result.cropRect;
}
updateLayer<ImageLayer>(result.layerId, {
cropRect: finalCropRect,
transform: {
...layer.transform,
x: layer.transform.x + result.cropRect.x,
y: layer.transform.y + result.cropRect.y,
width: result.cropRect.width,
height: result.cropRect.height,
},
});
}
}, [applyCrop, updateLayer, layer, imageDimensions]);
const handleResetCrop = useCallback(() => {
if (isCropping) {
updateCropRect({
x: 0,
y: 0,
width: layer.transform.width,
height: layer.transform.height,
});
} else {
updateLayer<ImageLayer>(layer.id, { cropRect: null });
}
}, [isCropping, layer, updateCropRect, updateLayer]);
const handleAspectRatioChange = useCallback(
(ratio: CropAspectRatio) => {
setCropAspectRatio(ratio);
if (!crop.cropRect) return;
const aspectConfig = ASPECT_RATIOS.find((r) => r.value === ratio);
if (!aspectConfig?.ratio) return;
const currentWidth = crop.cropRect.width;
const currentHeight = crop.cropRect.height;
const currentCenterX = crop.cropRect.x + currentWidth / 2;
const currentCenterY = crop.cropRect.y + currentHeight / 2;
let newWidth = currentWidth;
let newHeight = currentWidth / aspectConfig.ratio;
if (newHeight > layer.transform.height) {
newHeight = layer.transform.height;
newWidth = newHeight * aspectConfig.ratio;
}
if (newWidth > layer.transform.width) {
newWidth = layer.transform.width;
newHeight = newWidth / aspectConfig.ratio;
}
let newX = currentCenterX - newWidth / 2;
let newY = currentCenterY - newHeight / 2;
newX = Math.max(0, Math.min(newX, layer.transform.width - newWidth));
newY = Math.max(0, Math.min(newY, layer.transform.height - newHeight));
updateCropRect({
x: Math.round(newX),
y: Math.round(newY),
width: Math.round(newWidth),
height: Math.round(newHeight),
});
},
[crop.cropRect, layer.transform, setCropAspectRatio, updateCropRect]
);
const hasCrop = layer.cropRect !== null;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Crop</h4>
{hasCrop && !isCropping && (
<button
onClick={handleResetCrop}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{!isCropping ? (
<button
onClick={handleStartCrop}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors"
>
<Crop size={16} />
{hasCrop ? 'Adjust Crop' : 'Crop Image'}
</button>
) : (
<div className="space-y-3 p-3 bg-secondary/30 rounded-lg border border-border/50">
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-[11px] font-medium text-foreground">Aspect Ratio</label>
<button
onClick={() => setLockAspect(!lockAspect)}
className={`p-1 rounded transition-colors ${
lockAspect ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:text-foreground'
}`}
>
{lockAspect ? <Lock size={12} /> : <Unlock size={12} />}
</button>
</div>
<div className="grid grid-cols-3 gap-1">
{ASPECT_RATIOS.map((ar) => (
<button
key={ar.value}
onClick={() => handleAspectRatioChange(ar.value)}
className={`px-2 py-1.5 text-[10px] font-medium rounded transition-colors ${
crop.aspectRatio === ar.value
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80'
}`}
>
{ar.label}
</button>
))}
</div>
</div>
{crop.cropRect && (
<div className="space-y-2">
<label className="text-[11px] font-medium text-foreground">Crop Area</label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">X</label>
<input
type="number"
value={Math.round(crop.cropRect.x)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
x: Math.max(0, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Y</label>
<input
type="number"
value={Math.round(crop.cropRect.y)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
y: Math.max(0, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Width</label>
<input
type="number"
value={Math.round(crop.cropRect.width)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
width: Math.max(1, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[9px] text-muted-foreground">Height</label>
<input
type="number"
value={Math.round(crop.cropRect.height)}
onChange={(e) =>
updateCropRect({
...crop.cropRect!,
height: Math.max(1, Number(e.target.value)),
})
}
className="w-full px-2 py-1 text-[11px] bg-background border border-input rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
</div>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleResetCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<RotateCcw size={12} />
Reset
</button>
<button
onClick={cancelCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-secondary text-foreground rounded-lg font-medium text-[11px] hover:bg-secondary/80 transition-colors"
>
<X size={12} />
Cancel
</button>
<button
onClick={handleApplyCrop}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 bg-primary text-primary-foreground rounded-lg font-medium text-[11px] hover:bg-primary/90 transition-colors"
>
<Check size={12} />
Apply
</button>
</div>
</div>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,167 @@
import { useUIStore } from '../../../stores/ui-store';
import { Sun, Moon } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function DodgeBurnToolPanel() {
const { activeTool, dodgeBurnSettings, setDodgeBurnSettings } = useUIStore();
if (activeTool !== 'dodge' && activeTool !== 'burn') {
return null;
}
const toolTypes = [
{ id: 'dodge' as const, icon: Sun, label: 'Dodge' },
{ id: 'burn' as const, icon: Moon, label: 'Burn' },
];
const ranges = [
{ id: 'shadows' as const, label: 'Shadows' },
{ id: 'midtones' as const, label: 'Midtones' },
{ id: 'highlights' as const, label: 'Highlights' },
];
return (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
{dodgeBurnSettings.type === 'dodge' ? (
<Sun size={14} className="text-muted-foreground" />
) : (
<Moon size={14} className="text-muted-foreground" />
)}
<span className="text-xs font-medium">
{dodgeBurnSettings.type === 'dodge' ? 'Dodge Tool' : 'Burn Tool'}
</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tool</span>
<div className="flex gap-1">
{toolTypes.map((type) => (
<button
key={type.id}
onClick={() => setDodgeBurnSettings({ type: type.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.type === type.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<type.icon size={12} />
{type.label}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Range</span>
<div className="flex gap-1">
{ranges.map((range) => (
<button
key={range.id}
onClick={() => setDodgeBurnSettings({ range: range.id })}
className={`flex-1 px-2 py-1.5 text-[10px] rounded transition-colors ${
dodgeBurnSettings.range === range.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
{range.label}
</button>
))}
</div>
</div>
<Slider
label="Exposure"
value={dodgeBurnSettings.exposure}
min={1}
max={100}
unit="%"
onChange={(v) => setDodgeBurnSettings({ exposure: v })}
/>
<Slider
label="Size"
value={dodgeBurnSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setDodgeBurnSettings({ size: v })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full transition-all"
style={{
width: Math.min(dodgeBurnSettings.size, 80),
height: Math.min(dodgeBurnSettings.size, 80),
background:
dodgeBurnSettings.type === 'dodge'
? `radial-gradient(circle, rgba(255,255,255,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`
: `radial-gradient(circle, rgba(0,0,0,${dodgeBurnSettings.exposure / 100}) 0%, transparent 70%)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
{dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} {dodgeBurnSettings.range}
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> {dodgeBurnSettings.type === 'dodge' ? 'Lightens' : 'Darkens'} selected tonal range</li>
<li> Lower exposure for subtle adjustments</li>
<li> Build up effect with multiple strokes</li>
</ul>
</div>
</div>
</div>
);
}

View file

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

View file

@ -0,0 +1,153 @@
import { useUIStore } from '../../../stores/ui-store';
import { Eraser, Square, Pencil, Circle } from 'lucide-react';
interface SliderProps {
label: string;
value: number;
min: number;
max: number;
step?: number;
unit?: string;
onChange: (value: number) => void;
}
function Slider({ label, value, min, max, step = 1, unit = '', onChange }: SliderProps) {
const percentage = ((value - min) / (max - min)) * 100;
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{label}</span>
<span className="text-[10px] font-mono text-muted-foreground">
{value.toFixed(step < 1 ? 0 : 0)}{unit}
</span>
</div>
<input
type="range"
value={value}
min={min}
max={max}
step={step}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full h-1.5 appearance-none bg-secondary rounded-full cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:w-2.5
[&::-webkit-slider-thumb]:h-2.5
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-primary
[&::-webkit-slider-thumb]:shadow-sm
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, hsl(var(--primary)) 0%, hsl(var(--primary)) ${percentage}%, hsl(var(--secondary)) ${percentage}%, hsl(var(--secondary)) 100%)`,
}}
/>
</div>
);
}
export function EraserToolPanel() {
const { activeTool, eraserSettings, setEraserSettings } = useUIStore();
if (activeTool !== 'eraser') {
return null;
}
const eraserModes = [
{ id: 'brush' as const, icon: Circle, label: 'Brush' },
{ id: 'pencil' as const, icon: Pencil, label: 'Pencil' },
{ id: 'block' as const, icon: Square, label: 'Block' },
];
return (
<div className="border-b border-border">
<div className="px-3 py-2 flex items-center gap-2">
<Eraser size={14} className="text-muted-foreground" />
<span className="text-xs font-medium">Eraser Tool</span>
</div>
<div className="px-3 pb-3 space-y-3">
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Mode</span>
<div className="flex gap-1">
{eraserModes.map((mode) => (
<button
key={mode.id}
onClick={() => setEraserSettings({ mode: mode.id })}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-[10px] rounded transition-colors ${
eraserSettings.mode === mode.id
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50'
}`}
>
<mode.icon size={12} />
{mode.label}
</button>
))}
</div>
</div>
<Slider
label="Size"
value={eraserSettings.size}
min={1}
max={500}
unit="px"
onChange={(v) => setEraserSettings({ size: v })}
/>
<Slider
label="Hardness"
value={eraserSettings.hardness}
min={0}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ hardness: v })}
/>
<Slider
label="Opacity"
value={Math.round(eraserSettings.opacity * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ opacity: v / 100 })}
/>
<Slider
label="Flow"
value={Math.round(eraserSettings.flow * 100)}
min={1}
max={100}
unit="%"
onChange={(v) => setEraserSettings({ flow: v / 100 })}
/>
<div className="pt-2 border-t border-border">
<div className="flex items-center justify-center p-3 bg-secondary/30 rounded-lg">
<div
className="rounded-full bg-foreground transition-all"
style={{
width: Math.min(eraserSettings.size, 100),
height: Math.min(eraserSettings.size, 100),
opacity: eraserSettings.opacity,
filter: `blur(${(100 - eraserSettings.hardness) / 20}px)`,
}}
/>
</div>
<p className="text-[9px] text-muted-foreground text-center mt-1.5">
Brush preview
</p>
</div>
<div className="space-y-1.5">
<span className="text-[10px] text-muted-foreground font-medium">Tips</span>
<ul className="text-[9px] text-muted-foreground space-y-0.5">
<li> Hold Shift for straight lines</li>
<li> [ and ] to adjust size</li>
<li> Shift+[ and ] for hardness</li>
</ul>
</div>
</div>
</div>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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