feat: add item creation tools
All checks were successful
Deploy to Production / test (push) Successful in 37s
All checks were successful
Deploy to Production / test (push) Successful in 37s
This commit is contained in:
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
bun --watch bot/index.ts # Run bot + API with hot reload
|
||||
docker compose up # Start all services (bot, API, database)
|
||||
docker compose up app # Start just the app (bot + API)
|
||||
docker compose up db # Start just the database
|
||||
|
||||
# Testing
|
||||
bun test # Run all tests
|
||||
bun test path/to/file.test.ts # Run a single test file
|
||||
bun test shared/modules/economy # Run tests in a directory
|
||||
bun test --watch # Watch mode
|
||||
|
||||
# Database
|
||||
bun run db:push:local # Push schema changes (local)
|
||||
bun run db:studio # Open Drizzle Studio (localhost:4983)
|
||||
bun run generate # Generate Drizzle migrations (Docker)
|
||||
bun run migrate # Apply migrations (Docker)
|
||||
|
||||
# Admin Panel
|
||||
bun run panel:dev # Start Vite dev server for dashboard
|
||||
bun run panel:build # Build React dashboard for production
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Aurora is a Discord RPG bot + REST API running as a **single Bun process**. The bot and API share the same database client and services.
|
||||
|
||||
```
|
||||
bot/ # Discord bot
|
||||
├── commands/ # Slash commands by category (admin, economy, inventory, etc.)
|
||||
├── events/ # Discord event handlers
|
||||
├── lib/ # BotClient, handlers, loaders, embed helpers, commandUtils
|
||||
├── modules/ # Feature modules (views, interactions per domain)
|
||||
└── graphics/ # Canvas-based image generation (@napi-rs/canvas)
|
||||
|
||||
shared/ # Shared between bot and API
|
||||
├── db/ # Drizzle ORM client + schema (users, economy, inventory, quests, etc.)
|
||||
├── lib/ # env, config, errors, logger, types, utils
|
||||
└── modules/ # Domain services (economy, user, inventory, quest, moderation, etc.)
|
||||
|
||||
api/ # REST API (Bun HTTP server)
|
||||
└── src/routes/ # Route handlers for each domain
|
||||
|
||||
panel/ # React admin dashboard (Vite + Tailwind + Radix UI)
|
||||
```
|
||||
|
||||
**Key architectural details:**
|
||||
- Bot and API both import from `shared/` — do not duplicate logic.
|
||||
- Services in `shared/modules/` are singleton objects, not classes.
|
||||
- The database uses PostgreSQL 16+ via Drizzle ORM with `bigint` mode for Discord IDs and currency.
|
||||
- Feature modules follow a strict file suffix convention (see below).
|
||||
|
||||
## Import Conventions
|
||||
|
||||
Use path aliases (defined in `tsconfig.json`). Order: external packages → aliases → relative.
|
||||
|
||||
```typescript
|
||||
import { SlashCommandBuilder } from "discord.js"; // external
|
||||
import { economyService } from "@shared/modules/economy/economy.service"; // alias
|
||||
import { users } from "@db/schema"; // alias
|
||||
import { createErrorEmbed } from "@lib/embeds"; // alias
|
||||
import { localHelper } from "./helper"; // relative
|
||||
```
|
||||
|
||||
**Aliases:**
|
||||
- `@/*` → `bot/`
|
||||
- `@shared/*` → `shared/`
|
||||
- `@db/*` → `shared/db/`
|
||||
- `@lib/*` → `bot/lib/`
|
||||
- `@modules/*` → `bot/modules/`
|
||||
- `@commands/*` → `bot/commands/`
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Module File Suffixes
|
||||
|
||||
- `*.view.ts` — Creates Discord embeds/components
|
||||
- `*.interaction.ts` — Handles button/select/modal interactions
|
||||
- `*.service.ts` — Business logic (lives in `shared/modules/`)
|
||||
- `*.types.ts` — Module-specific TypeScript types
|
||||
- `*.test.ts` — Tests (co-located with source)
|
||||
|
||||
### Command Definition
|
||||
|
||||
```typescript
|
||||
export const commandName = createCommand({
|
||||
data: new SlashCommandBuilder().setName("name").setDescription("desc"),
|
||||
execute: async (interaction) => {
|
||||
await withCommandErrorHandling(interaction, async () => {
|
||||
const result = await service.method();
|
||||
await interaction.editReply({ embeds: [createSuccessEmbed(result)] });
|
||||
}, { ephemeral: true });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`withCommandErrorHandling` (from `@lib/commandUtils`) handles `deferReply`, `UserError` display, and unexpected error logging automatically.
|
||||
|
||||
### Service Pattern
|
||||
|
||||
```typescript
|
||||
export const serviceName = {
|
||||
methodName: async (params: ParamType): Promise<ReturnType> => {
|
||||
return await withTransaction(async (tx) => {
|
||||
// database operations
|
||||
});
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
import { UserError, SystemError } from "@shared/lib/errors";
|
||||
|
||||
throw new UserError("You don't have enough coins!"); // shown to user
|
||||
throw new SystemError("DB connection failed"); // logged, generic message shown
|
||||
```
|
||||
|
||||
### Database Transactions
|
||||
|
||||
```typescript
|
||||
import { withTransaction } from "@/lib/db";
|
||||
|
||||
return await withTransaction(async (tx) => {
|
||||
const user = await tx.query.users.findFirst({ where: eq(users.id, id) });
|
||||
await tx.update(users).set({ coins: newBalance }).where(eq(users.id, id));
|
||||
return user;
|
||||
}, existingTx); // pass existing tx for nested transactions
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Mock modules **before** imports. Use `bun:test`.
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
||||
|
||||
mock.module("@shared/db/DrizzleClient", () => ({
|
||||
DrizzleClient: { query: mockQuery },
|
||||
}));
|
||||
|
||||
describe("serviceName", () => {
|
||||
beforeEach(() => mockFn.mockClear());
|
||||
it("should handle expected case", async () => {
|
||||
mockFn.mockResolvedValue(testData);
|
||||
const result = await service.method(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Element | Convention | Example |
|
||||
| ---------------- | ---------------------- | -------------------------------- |
|
||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||
| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` |
|
||||
| Enums | PascalCase | `TimerType`, `TransactionType` |
|
||||
| Services | camelCase singleton | `economyService`, `userService` |
|
||||
| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` |
|
||||
| DB tables | snake_case | `users`, `moderation_cases` |
|
||||
| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` |
|
||||
| API routes | kebab-case | `/api/guild-settings` |
|
||||
|
||||
## Key Files
|
||||
|
||||
| Purpose | File |
|
||||
| ----------------- | -------------------------- |
|
||||
| Bot entry point | `bot/index.ts` |
|
||||
| Discord client | `bot/lib/BotClient.ts` |
|
||||
| DB schema index | `shared/db/schema.ts` |
|
||||
| Error classes | `shared/lib/errors.ts` |
|
||||
| Environment vars | `shared/lib/env.ts` |
|
||||
| Config loader | `shared/lib/config.ts` |
|
||||
| Embed helpers | `bot/lib/embeds.ts` |
|
||||
| Command utils | `bot/lib/commandUtils.ts` |
|
||||
| API server | `api/src/server.ts` |
|
||||
47
bun.lock
47
bun.lock
@@ -24,6 +24,7 @@
|
||||
"name": "panel",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
@@ -154,6 +155,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@imgly/background-removal": ["@imgly/background-removal@1.7.0", "", { "dependencies": { "lodash-es": "^4.17.21", "ndarray": "~1.0.0", "zod": "^3.23.8" }, "peerDependencies": { "onnxruntime-web": "1.21.0" } }, "sha512-/1ZryrMYg2ckIvJKoTu5Np50JfYMVffDMlVmppw/BdbN3pBTN7e6stI5/7E/LVh9DDzz6J588s7sWqul3fy5wA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -188,6 +191,26 @@
|
||||
|
||||
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.89", "", { "os": "win32", "cpu": "x64" }, "sha512-WMej0LZrIqIncQcx0JHaMXlnAG7sncwJh7obs/GBgp0xF9qABjwoRwIooMWCZkSansapKGNUHhamY6qEnFN7gA=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
@@ -348,6 +371,8 @@
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"flatbuffers": ["flatbuffers@25.9.23", "", {}, "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
@@ -358,6 +383,12 @@
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="],
|
||||
|
||||
"iota-array": ["iota-array@1.0.0", "", {}, "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="],
|
||||
|
||||
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
@@ -392,8 +423,12 @@
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
|
||||
|
||||
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
|
||||
@@ -406,20 +441,30 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"ndarray": ["ndarray@1.0.19", "", { "dependencies": { "iota-array": "^1.0.0", "is-buffer": "^1.0.2" } }, "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"onnxruntime-common": ["onnxruntime-common@1.21.0", "", {}, "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ=="],
|
||||
|
||||
"onnxruntime-web": ["onnxruntime-web@1.21.0", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.21.0", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-adzOe+7uI7lKz6pQNbAsLMQd2Fq5Jhmoxd8LZjJr8m3KvbFyiYyRxRiC57/XXD+jb18voppjeGAjoZmskXG+7A=="],
|
||||
|
||||
"panel": ["panel@workspace:panel"],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
@@ -476,6 +521,8 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@imgly/background-removal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@imgly/background-removal": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.564.0",
|
||||
|
||||
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
881
panel/src/pages/BackgroundRemoval.tsx
Normal file
@@ -0,0 +1,881 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Upload, Download, X, Wand2, ImageIcon, Loader2 } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECKERBOARD: React.CSSProperties = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||
`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
backgroundColor: "#2a2a2a",
|
||||
};
|
||||
|
||||
type BgPreset = { label: string; style: React.CSSProperties };
|
||||
|
||||
const BG_PRESETS: BgPreset[] = [
|
||||
{ label: "Checker", style: CHECKERBOARD },
|
||||
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||
];
|
||||
|
||||
// Max normalised distances for each keying space
|
||||
const MAX_RGB = Math.sqrt(3); // ≈ 1.732
|
||||
const MAX_HSV = 1.5; // sqrt(0.5² × 4 + 1² + 1² × 0.25)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] | null {
|
||||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebGL — shaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 aPos;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vUV = aPos * 0.5 + 0.5;
|
||||
vUV.y = 1.0 - vUV.y;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
const KEY_FRAG = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uImage;
|
||||
uniform vec3 uKey;
|
||||
uniform float uTol;
|
||||
uniform float uFeather;
|
||||
uniform float uSatMin;
|
||||
uniform float uSpill;
|
||||
uniform float uHueMode;
|
||||
varying vec2 vUV;
|
||||
|
||||
vec3 rgbToHsv(vec3 c) {
|
||||
float cmax = max(c.r, max(c.g, c.b));
|
||||
float cmin = min(c.r, min(c.g, c.b));
|
||||
float delta = cmax - cmin;
|
||||
float h = 0.0;
|
||||
float s = (cmax < 0.0001) ? 0.0 : delta / cmax;
|
||||
if (delta > 0.0001) {
|
||||
if (cmax == c.r) h = mod((c.g - c.b) / delta, 6.0);
|
||||
else if (cmax == c.g) h = (c.b - c.r) / delta + 2.0;
|
||||
else h = (c.r - c.g) / delta + 4.0;
|
||||
h /= 6.0;
|
||||
}
|
||||
return vec3(h, s, cmax);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 c = texture2D(uImage, vUV);
|
||||
vec3 hsv = rgbToHsv(c.rgb);
|
||||
vec3 keyHsv = rgbToHsv(uKey);
|
||||
|
||||
float d;
|
||||
if (uHueMode > 0.5) {
|
||||
float dh = abs(hsv.x - keyHsv.x);
|
||||
if (dh > 0.5) dh = 1.0 - dh;
|
||||
float ds = abs(hsv.y - keyHsv.y);
|
||||
float dv = abs(hsv.z - keyHsv.z);
|
||||
d = sqrt(dh * dh * 4.0 + ds * ds + dv * dv * 0.25);
|
||||
} else {
|
||||
d = distance(c.rgb, uKey);
|
||||
}
|
||||
|
||||
float a = c.a;
|
||||
if (hsv.y >= uSatMin) {
|
||||
if (d <= uTol) {
|
||||
a = 0.0;
|
||||
} else if (uFeather > 0.0 && d <= uTol + uFeather) {
|
||||
a = (d - uTol) / uFeather * c.a;
|
||||
}
|
||||
}
|
||||
|
||||
vec3 rgb = c.rgb;
|
||||
if (uSpill > 0.0) {
|
||||
float edgeDist = max(0.0, d - uTol);
|
||||
float spillZone = max(uFeather + uTol * 0.5, 0.01);
|
||||
float spillFact = clamp(1.0 - edgeDist / spillZone, 0.0, 1.0) * uSpill;
|
||||
|
||||
if (uKey.g >= uKey.r && uKey.g >= uKey.b) {
|
||||
float excess = rgb.g - max(rgb.r, rgb.b);
|
||||
if (excess > 0.0) rgb.g -= excess * spillFact;
|
||||
} else if (uKey.b >= uKey.r && uKey.b >= uKey.g) {
|
||||
float excess = rgb.b - max(rgb.r, rgb.g);
|
||||
if (excess > 0.0) rgb.b -= excess * spillFact;
|
||||
} else {
|
||||
float excess = rgb.r - max(rgb.g, rgb.b);
|
||||
if (excess > 0.0) rgb.r -= excess * spillFact;
|
||||
}
|
||||
}
|
||||
|
||||
gl_FragColor = vec4(rgb, a);
|
||||
}`;
|
||||
|
||||
const HALO_FRAG = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uKeyed;
|
||||
uniform float uHaloStr;
|
||||
uniform float uHaloRadius;
|
||||
uniform vec2 uTexelSize;
|
||||
varying vec2 vUV;
|
||||
|
||||
void main() {
|
||||
vec4 c = texture2D(uKeyed, vUV);
|
||||
|
||||
if (uHaloStr <= 0.0) {
|
||||
gl_FragColor = c;
|
||||
return;
|
||||
}
|
||||
|
||||
vec2 r = uTexelSize * uHaloRadius;
|
||||
vec2 rd = r * 0.7071;
|
||||
|
||||
float minA = c.a;
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2( r.x, 0.0 )).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2(-r.x, 0.0 )).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, r.y )).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2( 0.0, -r.y )).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, rd.y)).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, rd.y)).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2( rd.x, -rd.y)).a);
|
||||
minA = min(minA, texture2D(uKeyed, vUV + vec2(-rd.x, -rd.y)).a);
|
||||
|
||||
gl_FragColor = vec4(c.rgb, mix(c.a, minA, uHaloStr));
|
||||
}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebGL — types + init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type GlState = {
|
||||
gl: WebGLRenderingContext;
|
||||
kProg: WebGLProgram;
|
||||
srcTex: WebGLTexture;
|
||||
fbo: WebGLFramebuffer;
|
||||
fboTex: WebGLTexture;
|
||||
uKey: WebGLUniformLocation;
|
||||
uTol: WebGLUniformLocation;
|
||||
uFeather: WebGLUniformLocation;
|
||||
uSatMin: WebGLUniformLocation;
|
||||
uSpill: WebGLUniformLocation;
|
||||
uHueMode: WebGLUniformLocation;
|
||||
hProg: WebGLProgram;
|
||||
uHaloStr: WebGLUniformLocation;
|
||||
uHaloRadius: WebGLUniformLocation;
|
||||
uTexelSize: WebGLUniformLocation;
|
||||
};
|
||||
|
||||
function compileShader(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
|
||||
const s = gl.createShader(type)!;
|
||||
gl.shaderSource(s, src);
|
||||
gl.compileShader(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
function makeProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader): WebGLProgram {
|
||||
const p = gl.createProgram()!;
|
||||
gl.attachShader(p, vs);
|
||||
gl.attachShader(p, fs);
|
||||
gl.bindAttribLocation(p, 0, "aPos");
|
||||
gl.linkProgram(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function makeTexture(gl: WebGLRenderingContext): WebGLTexture {
|
||||
const t = gl.createTexture()!;
|
||||
gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
return t;
|
||||
}
|
||||
|
||||
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||
const gl = canvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!gl) return null;
|
||||
|
||||
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT);
|
||||
const kFrag = compileShader(gl, gl.FRAGMENT_SHADER, KEY_FRAG);
|
||||
const hFrag = compileShader(gl, gl.FRAGMENT_SHADER, HALO_FRAG);
|
||||
const kProg = makeProgram(gl, vs, kFrag);
|
||||
const hProg = makeProgram(gl, vs, hFrag);
|
||||
|
||||
const buf = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const srcTex = makeTexture(gl);
|
||||
const fboTex = makeTexture(gl);
|
||||
|
||||
const fbo = gl.createFramebuffer()!;
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
|
||||
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, fboTex, 0);
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
|
||||
return {
|
||||
gl, kProg, hProg, srcTex, fbo, fboTex,
|
||||
uKey: gl.getUniformLocation(kProg, "uKey")!,
|
||||
uTol: gl.getUniformLocation(kProg, "uTol")!,
|
||||
uFeather: gl.getUniformLocation(kProg, "uFeather")!,
|
||||
uSatMin: gl.getUniformLocation(kProg, "uSatMin")!,
|
||||
uSpill: gl.getUniformLocation(kProg, "uSpill")!,
|
||||
uHueMode: gl.getUniformLocation(kProg, "uHueMode")!,
|
||||
uHaloStr: gl.getUniformLocation(hProg, "uHaloStr")!,
|
||||
uHaloRadius: gl.getUniformLocation(hProg, "uHaloRadius")!,
|
||||
uTexelSize: gl.getUniformLocation(hProg, "uTexelSize")!,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI remove tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AiStatus = "idle" | "loading" | "done" | "error";
|
||||
|
||||
function AiRemoveTab({ imageFile, imageSrc, onClear }: {
|
||||
imageFile: File;
|
||||
imageSrc: string;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [status, setStatus] = useState<AiStatus>("idle");
|
||||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||
const [bgPreset, setBgPreset] = useState(0);
|
||||
const [progress, setProgress] = useState("");
|
||||
|
||||
const handleRemove = async () => {
|
||||
setStatus("loading");
|
||||
setProgress("Loading AI model…");
|
||||
try {
|
||||
const { removeBackground } = await import("@imgly/background-removal");
|
||||
setProgress("Removing background…");
|
||||
const blob = await removeBackground(imageSrc, {
|
||||
progress: (_key: string, current: number, total: number) => {
|
||||
if (total > 0) {
|
||||
setProgress(`Downloading model… ${Math.round((current / total) * 100)}%`);
|
||||
}
|
||||
},
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
setResultUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
|
||||
setStatus("done");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!resultUrl) return;
|
||||
const a = document.createElement("a");
|
||||
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_nobg.png";
|
||||
a.href = resultUrl;
|
||||
a.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||
{imageFile.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||
"hover:text-destructive hover:border-destructive transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
{status !== "done" ? (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={status === "loading"}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||
status === "loading"
|
||||
? "bg-raised border border-border text-text-tertiary cursor-not-allowed"
|
||||
: "bg-primary text-white hover:bg-primary/90",
|
||||
)}
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<><Loader2 className="w-3.5 h-3.5 animate-spin" /> {progress}</>
|
||||
) : (
|
||||
<><Wand2 className="w-3.5 h-3.5" /> Remove Background</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold bg-primary text-white hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "error" && (
|
||||
<p className="text-xs text-destructive">
|
||||
Something went wrong. Check the console for details.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Side-by-side */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">Original</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<img src={imageSrc} className="w-full block" alt="Original" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||
Result — transparent background
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{BG_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
title={preset.label}
|
||||
onClick={() => setBgPreset(i)}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border transition-all",
|
||||
i === bgPreset
|
||||
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
style={preset.style}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{resultUrl ? (
|
||||
<div style={BG_PRESETS[bgPreset].style}>
|
||||
<img src={resultUrl} className="w-full block" alt="Result" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||
{status === "loading" ? (
|
||||
<><Loader2 className="w-10 h-10 opacity-40 animate-spin" /><span className="text-xs opacity-40 text-center">{progress}</span></>
|
||||
) : (
|
||||
<><ImageIcon className="w-10 h-10 opacity-20" /><span className="text-xs opacity-40 text-center">Click Remove Background to process</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackgroundRemoval component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Mode = "chroma" | "ai";
|
||||
|
||||
export function BackgroundRemoval() {
|
||||
const [mode, setMode] = useState<Mode>("chroma");
|
||||
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
const [keyColor, setKeyColor] = useState<[number, number, number] | null>(null);
|
||||
const [hexInput, setHexInput] = useState("");
|
||||
const [tolerance, setTolerance] = useState(30);
|
||||
const [feather, setFeather] = useState(10);
|
||||
const [satMin, setSatMin] = useState(0);
|
||||
const [spillStr, setSpillStr] = useState(0);
|
||||
const [hueMode, setHueMode] = useState(false);
|
||||
const [haloStr, setHaloStr] = useState(0);
|
||||
const [haloRadius, setHaloRadius] = useState(2);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [bgPreset, setBgPreset] = useState(0);
|
||||
|
||||
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const glRef = useRef<GlState | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyColor) setHexInput(rgbToHex(...keyColor));
|
||||
}, [keyColor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageSrc) return;
|
||||
setImageReady(false);
|
||||
setKeyColor(null);
|
||||
setHexInput("");
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const src = sourceCanvasRef.current;
|
||||
if (!src) return;
|
||||
src.width = img.naturalWidth;
|
||||
src.height = img.naturalHeight;
|
||||
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||
|
||||
const glCanvas = glCanvasRef.current;
|
||||
if (!glCanvas) return;
|
||||
glCanvas.width = img.naturalWidth;
|
||||
glCanvas.height = img.naturalHeight;
|
||||
|
||||
if (!glRef.current) {
|
||||
glRef.current = initGl(glCanvas);
|
||||
if (!glRef.current) {
|
||||
console.error("BackgroundRemoval: WebGL not supported");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { gl, srcTex, fboTex } = glRef.current;
|
||||
gl.bindTexture(gl.TEXTURE_2D, srcTex);
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
|
||||
gl.bindTexture(gl.TEXTURE_2D, fboTex);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, img.naturalWidth, img.naturalHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
||||
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||
setImageReady(true);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
const state = glRef.current;
|
||||
if (!state || !imageReady || !keyColor) return;
|
||||
|
||||
const w = glCanvasRef.current!.width;
|
||||
const h = glCanvasRef.current!.height;
|
||||
const { gl } = state;
|
||||
const MAX = hueMode ? MAX_HSV : MAX_RGB;
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, state.fbo);
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.useProgram(state.kProg);
|
||||
gl.bindTexture(gl.TEXTURE_2D, state.srcTex);
|
||||
gl.uniform3f(state.uKey, keyColor[0] / 255, keyColor[1] / 255, keyColor[2] / 255);
|
||||
gl.uniform1f(state.uTol, (tolerance / 100) * MAX);
|
||||
gl.uniform1f(state.uFeather, (feather / 100) * MAX);
|
||||
gl.uniform1f(state.uSatMin, satMin / 100);
|
||||
gl.uniform1f(state.uSpill, spillStr / 100);
|
||||
gl.uniform1f(state.uHueMode, hueMode ? 1.0 : 0.0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
||||
gl.viewport(0, 0, w, h);
|
||||
gl.useProgram(state.hProg);
|
||||
gl.bindTexture(gl.TEXTURE_2D, state.fboTex);
|
||||
gl.uniform1f(state.uHaloStr, haloStr / 100);
|
||||
gl.uniform1f(state.uHaloRadius, haloRadius);
|
||||
gl.uniform2f(state.uTexelSize, 1 / w, 1 / h);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}, [keyColor, tolerance, feather, satMin, spillStr, hueMode, haloStr, haloRadius, imageReady]);
|
||||
|
||||
const loadFile = useCallback((file: File) => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(URL.createObjectURL(file));
|
||||
setImageFile(file);
|
||||
}, [imageSrc]);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith("image/")) loadFile(file);
|
||||
};
|
||||
|
||||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = sourceCanvasRef.current;
|
||||
if (!canvas || !imageReady) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const x = Math.floor((e.clientX - rect.left) * scaleX);
|
||||
const y = Math.floor((e.clientY - rect.top) * scaleY);
|
||||
const px = canvas.getContext("2d")!.getImageData(x, y, 1, 1).data;
|
||||
setKeyColor([px[0], px[1], px[2]]);
|
||||
};
|
||||
|
||||
const handleHexInput = (v: string) => {
|
||||
setHexInput(v);
|
||||
const parsed = hexToRgb(v);
|
||||
if (parsed) setKeyColor(parsed);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const glCanvas = glCanvasRef.current;
|
||||
if (!glCanvas || !keyColor || !imageFile) return;
|
||||
glCanvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_transparent.png";
|
||||
a.href = url;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setImageFile(null);
|
||||
setImageReady(false);
|
||||
setKeyColor(null);
|
||||
setHexInput("");
|
||||
glRef.current = null;
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [imageSrc]);
|
||||
|
||||
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||
if (!imageSrc) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||
{/* Page header + mode toggle */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-1">
|
||||
Background Removal
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upload an image then remove its background — either by selecting a
|
||||
key color (chroma key) or using the AI model.
|
||||
</p>
|
||||
</div>
|
||||
<ModeToggle mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||
)}
|
||||
>
|
||||
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||
const hasResult = imageReady && keyColor !== null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
{/* Page header + mode toggle */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground flex-1">
|
||||
Background Removal
|
||||
</h2>
|
||||
<ModeToggle mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
|
||||
{/* AI tab */}
|
||||
{mode === "ai" && (
|
||||
<AiRemoveTab imageFile={imageFile!} imageSrc={imageSrc} onClear={clearAll} />
|
||||
)}
|
||||
|
||||
{/* Chroma key tab */}
|
||||
{mode === "chroma" && (
|
||||
<>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||
{imageFile?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||
"hover:text-destructive hover:border-destructive transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!hasResult}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||
hasResult
|
||||
? "bg-primary text-white hover:bg-primary/90"
|
||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||
{/* Row 1 — Key color + mode */}
|
||||
<div className="flex flex-wrap gap-6 items-center">
|
||||
<div className="space-y-1.5 shrink-0">
|
||||
<p className="text-xs font-medium text-text-secondary">Key Color</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-md border border-border shadow-inner shrink-0"
|
||||
style={
|
||||
keyColor
|
||||
? { backgroundColor: rgbToHex(...keyColor) }
|
||||
: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg,#444 25%,transparent 25%),linear-gradient(-45deg,#444 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#444 75%),linear-gradient(-45deg,transparent 75%,#444 75%)",
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0,0 4px,4px -4px,-4px 0",
|
||||
backgroundColor: "#2a2a2a",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={hexInput}
|
||||
onChange={(e) => handleHexInput(e.target.value)}
|
||||
placeholder="#rrggbb"
|
||||
maxLength={7}
|
||||
spellCheck={false}
|
||||
className={cn(
|
||||
"w-[76px] text-xs font-mono bg-transparent border rounded px-1.5 py-0.5",
|
||||
"text-text-secondary focus:outline-none transition-colors",
|
||||
hexInput && !hexToRgb(hexInput)
|
||||
? "border-destructive focus:border-destructive"
|
||||
: "border-border focus:border-primary",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 shrink-0">
|
||||
<p className="text-xs font-medium text-text-secondary">Keying Mode</p>
|
||||
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium">
|
||||
<button
|
||||
onClick={() => setHueMode(false)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 transition-colors",
|
||||
!hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
RGB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHueMode(true)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 transition-colors border-l border-border",
|
||||
hueMode ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
HSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!keyColor && (
|
||||
<span className="text-xs text-text-tertiary flex items-center gap-1.5 ml-auto">
|
||||
<Wand2 className="w-3.5 h-3.5" /> Click the image to pick a key color
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Matte */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Matte</p>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Tolerance</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{tolerance}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={tolerance} onChange={(e) => setTolerance(Number(e.target.value))} className="w-full accent-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Sat. Gate</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{satMin}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={satMin} onChange={(e) => setSatMin(Number(e.target.value))} className="w-full accent-primary" />
|
||||
<p className="text-[10px] text-text-tertiary leading-tight">Skip pixels below this saturation — preserves neutral tones</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Edge Feather</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{feather}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="50" value={feather} onChange={(e) => setFeather(Number(e.target.value))} className="w-full accent-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 — Cleanup */}
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-tertiary mb-2">Cleanup</p>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Despill</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{spillStr}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={spillStr} onChange={(e) => setSpillStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||
<p className="text-[10px] text-text-tertiary leading-tight">Suppress key-color fringing on subject edges</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Halo Remove</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{haloStr}%</span>
|
||||
</div>
|
||||
<input type="range" min="0" max="100" value={haloStr} onChange={(e) => setHaloStr(Number(e.target.value))} className="w-full accent-primary" />
|
||||
<p className="text-[10px] text-text-tertiary leading-tight">Erode the matte inward to eliminate bright rim pixels</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[130px]">
|
||||
<div className="flex justify-between">
|
||||
<p className="text-xs font-medium text-text-secondary">Halo Radius</p>
|
||||
<span className="text-xs font-mono text-text-tertiary">{haloRadius} px</span>
|
||||
</div>
|
||||
<input type="range" min="1" max="8" step="1" value={haloRadius} onChange={(e) => setHaloRadius(Number(e.target.value))} className="w-full accent-primary" disabled={haloStr === 0} />
|
||||
<p className="text-[10px] text-text-tertiary leading-tight">How far to look for transparent neighbours</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side view */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Original — click to pick key color
|
||||
</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<canvas ref={sourceCanvasRef} className="w-full cursor-crosshair block" onClick={handleCanvasClick} title="Click a pixel to set it as the chroma key color" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||
Result — transparent background
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{BG_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
title={preset.label}
|
||||
onClick={() => setBgPreset(i)}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border transition-all",
|
||||
i === bgPreset
|
||||
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
style={preset.style}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !hasResult && "hidden")}>
|
||||
<canvas ref={glCanvasRef} className="w-full block" />
|
||||
</div>
|
||||
{!hasResult && (
|
||||
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||
{imageReady ? "Click the image on the left to pick a key color" : "Loading image…"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ModeToggle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-md border border-border overflow-hidden text-xs font-medium shrink-0">
|
||||
<button
|
||||
onClick={() => onChange("chroma")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 transition-colors",
|
||||
mode === "chroma" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
Chroma Key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChange("ai")}
|
||||
className={cn(
|
||||
"px-3 py-1.5 transition-colors border-l border-border",
|
||||
mode === "ai" ? "bg-primary text-white" : "text-text-secondary hover:text-foreground hover:bg-raised",
|
||||
)}
|
||||
>
|
||||
AI Remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
509
panel/src/pages/CanvasTool.tsx
Normal file
509
panel/src/pages/CanvasTool.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Upload, Download, X, Lock, Unlock, ImageIcon, Maximize2 } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECKERBOARD: React.CSSProperties = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||
`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
backgroundColor: "#2a2a2a",
|
||||
};
|
||||
|
||||
type BgPreset = { label: string; style: React.CSSProperties };
|
||||
|
||||
const BG_PRESETS: BgPreset[] = [
|
||||
{ label: "Checker", style: CHECKERBOARD },
|
||||
{ label: "White", style: { backgroundColor: "#ffffff" } },
|
||||
{ label: "Black", style: { backgroundColor: "#000000" } },
|
||||
{ label: "Red", style: { backgroundColor: "#e53e3e" } },
|
||||
{ label: "Green", style: { backgroundColor: "#38a169" } },
|
||||
{ label: "Blue", style: { backgroundColor: "#3182ce" } },
|
||||
];
|
||||
|
||||
type ScaleMode = "fit" | "fill" | "stretch" | "original";
|
||||
|
||||
const SCALE_MODES: { id: ScaleMode; label: string; desc: string }[] = [
|
||||
{ id: "fit", label: "Fit", desc: "Scale to fit canvas, preserve ratio (letterbox)" },
|
||||
{ id: "fill", label: "Fill", desc: "Scale to fill canvas, preserve ratio (crop edges)" },
|
||||
{ id: "stretch", label: "Stretch", desc: "Stretch to exact dimensions, ignore ratio" },
|
||||
{ id: "original", label: "Original", desc: "No scaling — align/center only" },
|
||||
];
|
||||
|
||||
type Align = [-1 | 0 | 1, -1 | 0 | 1];
|
||||
|
||||
const ALIGN_GRID: Align[] = [
|
||||
[-1, -1], [0, -1], [1, -1],
|
||||
[-1, 0], [0, 0], [1, 0],
|
||||
[-1, 1], [0, 1], [1, 1],
|
||||
];
|
||||
|
||||
const SIZE_PRESETS: { label: string; w: number; h: number }[] = [
|
||||
{ label: "64", w: 64, h: 64 },
|
||||
{ label: "128", w: 128, h: 128 },
|
||||
{ label: "256", w: 256, h: 256 },
|
||||
{ label: "512", w: 512, h: 512 },
|
||||
{ label: "1024", w: 1024, h: 1024 },
|
||||
{ label: "960×540", w: 960, h: 540 },
|
||||
{ label: "1920×1080", w: 1920, h: 1080 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CanvasTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CanvasTool() {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const [naturalW, setNaturalW] = useState(1);
|
||||
const [naturalH, setNaturalH] = useState(1);
|
||||
|
||||
const [outW, setOutW] = useState("256");
|
||||
const [outH, setOutH] = useState("256");
|
||||
const [aspectLock, setAspectLock] = useState(true);
|
||||
const [scaleMode, setScaleMode] = useState<ScaleMode>("fit");
|
||||
const [alignment, setAlignment] = useState<Align>([0, 0]);
|
||||
const [bgPreset, setBgPreset] = useState(0);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// previewCanvasRef — drawn to directly on every setting change; no toDataURL
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// ── Load image onto hidden source canvas ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!imageSrc) return;
|
||||
setImageReady(false);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = sourceCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||
setNaturalW(img.naturalWidth);
|
||||
setNaturalH(img.naturalHeight);
|
||||
setOutW(String(img.naturalWidth));
|
||||
setOutH(String(img.naturalHeight));
|
||||
setImageReady(true);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// ── Draw output directly into previewCanvasRef whenever settings change ────
|
||||
// drawImage is GPU-accelerated; skipping toDataURL eliminates the main bottleneck.
|
||||
useEffect(() => {
|
||||
if (!imageReady) return;
|
||||
const src = sourceCanvasRef.current;
|
||||
const prev = previewCanvasRef.current;
|
||||
if (!src || !prev) return;
|
||||
|
||||
const w = parseInt(outW);
|
||||
const h = parseInt(outH);
|
||||
if (!w || !h || w <= 0 || h <= 0) return;
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
prev.width = w;
|
||||
prev.height = h;
|
||||
const ctx = prev.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const srcW = src.width;
|
||||
const srcH = src.height;
|
||||
|
||||
let drawW: number;
|
||||
let drawH: number;
|
||||
|
||||
if (scaleMode === "fit") {
|
||||
const scale = Math.min(w / srcW, h / srcH);
|
||||
drawW = srcW * scale;
|
||||
drawH = srcH * scale;
|
||||
} else if (scaleMode === "fill") {
|
||||
const scale = Math.max(w / srcW, h / srcH);
|
||||
drawW = srcW * scale;
|
||||
drawH = srcH * scale;
|
||||
} else if (scaleMode === "stretch") {
|
||||
drawW = w;
|
||||
drawH = h;
|
||||
} else {
|
||||
drawW = srcW;
|
||||
drawH = srcH;
|
||||
}
|
||||
|
||||
const x = (w - drawW) * (alignment[0] + 1) / 2;
|
||||
const y = (h - drawH) * (alignment[1] + 1) / 2;
|
||||
|
||||
ctx.drawImage(src, x, y, drawW, drawH);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [imageReady, outW, outH, scaleMode, alignment]);
|
||||
|
||||
// ── Width / height with optional aspect lock ───────────────────────────────
|
||||
const handleWChange = (v: string) => {
|
||||
setOutW(v);
|
||||
if (aspectLock) {
|
||||
const n = parseInt(v);
|
||||
if (!isNaN(n) && n > 0) setOutH(String(Math.round(n * naturalH / naturalW)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleHChange = (v: string) => {
|
||||
setOutH(v);
|
||||
if (aspectLock) {
|
||||
const n = parseInt(v);
|
||||
if (!isNaN(n) && n > 0) setOutW(String(Math.round(n * naturalW / naturalH)));
|
||||
}
|
||||
};
|
||||
|
||||
// ── File loading ───────────────────────────────────────────────────────────
|
||||
const loadFile = useCallback((file: File) => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(URL.createObjectURL(file));
|
||||
setImageFile(file);
|
||||
}, [imageSrc]);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith("image/")) loadFile(file);
|
||||
};
|
||||
|
||||
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||
const handleDownload = () => {
|
||||
const prev = previewCanvasRef.current;
|
||||
if (!prev || !imageReady || !imageFile) return;
|
||||
prev.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.download = imageFile.name.replace(/\.[^.]+$/, "") + `_${outW}x${outH}.png`;
|
||||
a.href = url;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setImageFile(null);
|
||||
setImageReady(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [imageSrc]);
|
||||
|
||||
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||
if (!imageSrc) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-1">Canvas Tool</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upload an image to resize, scale, or center it on a canvas of any size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||
)}
|
||||
>
|
||||
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||
const alignDisabled = scaleMode === "stretch";
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
{/* Hidden source canvas */}
|
||||
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground flex-1">Canvas Tool</h2>
|
||||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||
{imageFile?.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary font-mono">
|
||||
{naturalW}×{naturalH}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||
"hover:text-destructive hover:border-destructive transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!imageReady}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||
imageReady
|
||||
? "bg-primary text-white hover:bg-primary/90"
|
||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 space-y-4">
|
||||
{/* Row 1: output size */}
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-text-secondary">Output Size</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number" min="1" max="4096" value={outW}
|
||||
onChange={(e) => handleWChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
placeholder="W"
|
||||
/>
|
||||
<span className="text-text-tertiary text-xs">×</span>
|
||||
<input
|
||||
type="number" min="1" max="4096" value={outH}
|
||||
onChange={(e) => handleHChange(e.target.value)}
|
||||
className={cn(
|
||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
placeholder="H"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setAspectLock((l) => !l)}
|
||||
title={aspectLock ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
className={cn(
|
||||
"p-1.5 rounded-md border transition-colors",
|
||||
aspectLock
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-text-tertiary hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{aspectLock ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size presets */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-text-secondary">Presets</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => { setOutW(String(naturalW)); setOutH(String(naturalH)); }}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs border transition-colors",
|
||||
outW === String(naturalW) && outH === String(naturalH)
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
Original
|
||||
</button>
|
||||
{SIZE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => {
|
||||
if (aspectLock) {
|
||||
const scale = Math.min(p.w / naturalW, p.h / naturalH);
|
||||
setOutW(String(Math.round(naturalW * scale)));
|
||||
setOutH(String(Math.round(naturalH * scale)));
|
||||
} else {
|
||||
setOutW(String(p.w));
|
||||
setOutH(String(p.h));
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"px-2 py-1 rounded text-xs border transition-colors",
|
||||
outW === String(p.w) && outH === String(p.h)
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: scale mode + alignment */}
|
||||
<div className="flex flex-wrap gap-6 items-start">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-text-secondary">Scale Mode</p>
|
||||
<div className="flex gap-1">
|
||||
{SCALE_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setScaleMode(m.id)}
|
||||
title={m.desc}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-xs font-medium border transition-colors",
|
||||
scaleMode === m.id
|
||||
? "border-primary text-primary bg-primary/10"
|
||||
: "border-border text-text-tertiary hover:border-primary/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary">
|
||||
{SCALE_MODES.find((m) => m.id === scaleMode)?.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-1.5", alignDisabled && "opacity-40 pointer-events-none")}>
|
||||
<p className="text-xs font-medium text-text-secondary">
|
||||
{scaleMode === "fill" ? "Crop Position" : "Alignment"}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-0.5 w-fit">
|
||||
{ALIGN_GRID.map(([col, row], i) => {
|
||||
const active = alignment[0] === col && alignment[1] === row;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setAlignment([col, row])}
|
||||
title={`${row === -1 ? "Top" : row === 0 ? "Middle" : "Bottom"}-${col === -1 ? "Left" : col === 0 ? "Center" : "Right"}`}
|
||||
className={cn(
|
||||
"w-7 h-7 rounded flex items-center justify-center border transition-colors",
|
||||
active
|
||||
? "border-primary bg-primary/20"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
active ? "bg-primary" : "bg-text-tertiary/40",
|
||||
)} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side preview */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Original */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Original — {naturalW}×{naturalH}
|
||||
</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div style={CHECKERBOARD}>
|
||||
<img src={imageSrc} alt="Source" className="w-full block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result — canvas drawn directly, no toDataURL */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider flex-1">
|
||||
Result — {outW || "?"}×{outH || "?"}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{BG_PRESETS.map((preset, i) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
title={preset.label}
|
||||
onClick={() => setBgPreset(i)}
|
||||
className={cn(
|
||||
"w-5 h-5 rounded border transition-all",
|
||||
i === bgPreset
|
||||
? "ring-2 ring-primary ring-offset-1 ring-offset-background border-transparent"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
style={preset.style}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div style={BG_PRESETS[bgPreset].style} className={cn("w-full", !imageReady && "hidden")}>
|
||||
<canvas ref={previewCanvasRef} className="w-full block" />
|
||||
</div>
|
||||
{!imageReady && (
|
||||
<div className="aspect-square flex flex-col items-center justify-center text-text-tertiary gap-3 p-8">
|
||||
<ImageIcon className="w-10 h-10 opacity-20" />
|
||||
<span className="text-xs opacity-40 text-center leading-relaxed">
|
||||
Loading image…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info strip */}
|
||||
{imageReady && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
||||
<Maximize2 className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
Output: <span className="font-mono text-foreground">{outW}×{outH} px</span>
|
||||
{" · "}
|
||||
Mode: <span className="text-foreground capitalize">{scaleMode}</span>
|
||||
{scaleMode !== "stretch" && (
|
||||
<>
|
||||
{" · "}
|
||||
Align:{" "}
|
||||
<span className="text-foreground">
|
||||
{alignment[1] === -1 ? "Top" : alignment[1] === 0 ? "Middle" : "Bottom"}
|
||||
-{alignment[0] === -1 ? "Left" : alignment[0] === 0 ? "Center" : "Right"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
653
panel/src/pages/CropTool.tsx
Normal file
653
panel/src/pages/CropTool.tsx
Normal file
@@ -0,0 +1,653 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Upload, Download, X, ImageIcon, Crosshair, Maximize2 } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECKERBOARD: React.CSSProperties = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||
`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
backgroundColor: "#2a2a2a",
|
||||
};
|
||||
|
||||
// Extra space around the output rect in the editor so you can see the image
|
||||
// when it extends (or will be panned) outside the output boundaries.
|
||||
const PAD = 120;
|
||||
const HANDLE_HIT = 8;
|
||||
const MIN_CROP = 4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-level checkerboard tile cache
|
||||
// Avoids recreating a canvas element on every drawEditor call.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _checkerTile: HTMLCanvasElement | null = null;
|
||||
|
||||
function checkerTile(): HTMLCanvasElement {
|
||||
if (_checkerTile) return _checkerTile;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = 16; c.height = 16;
|
||||
const ctx = c.getContext("2d")!;
|
||||
ctx.fillStyle = "#2a2a2a"; ctx.fillRect(0, 0, 16, 16);
|
||||
ctx.fillStyle = "#3d3d3d"; ctx.fillRect(0, 0, 8, 8); ctx.fillRect(8, 8, 8, 8);
|
||||
_checkerTile = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// "pan" = drag inside output rect to move the image
|
||||
type DragHandle =
|
||||
| "nw" | "n" | "ne"
|
||||
| "w" | "e"
|
||||
| "sw" | "s" | "se"
|
||||
| "pan";
|
||||
|
||||
type DragState = {
|
||||
handle: DragHandle;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origW: number;
|
||||
origH: number;
|
||||
origImgX: number;
|
||||
origImgY: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getHandlePoints(
|
||||
cx0: number, cy0: number, cx1: number, cy1: number
|
||||
): [DragHandle, number, number][] {
|
||||
const mx = (cx0 + cx1) / 2;
|
||||
const my = (cy0 + cy1) / 2;
|
||||
return [
|
||||
["nw", cx0, cy0], ["n", mx, cy0], ["ne", cx1, cy0],
|
||||
["w", cx0, my], ["e", cx1, my],
|
||||
["sw", cx0, cy1], ["s", mx, cy1], ["se", cx1, cy1],
|
||||
];
|
||||
}
|
||||
|
||||
function hitHandle(
|
||||
px: number, py: number,
|
||||
cx0: number, cy0: number, cx1: number, cy1: number
|
||||
): DragHandle | null {
|
||||
for (const [name, hx, hy] of getHandlePoints(cx0, cy0, cx1, cy1)) {
|
||||
if (Math.abs(px - hx) <= HANDLE_HIT && Math.abs(py - hy) <= HANDLE_HIT) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
if (px >= cx0 && px <= cx1 && py >= cy0 && py <= cy1) return "pan";
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleCursor(h: DragHandle | null, dragging = false): string {
|
||||
if (!h) return "default";
|
||||
if (h === "pan") return dragging ? "grabbing" : "grab";
|
||||
if (h === "nw" || h === "se") return "nwse-resize";
|
||||
if (h === "ne" || h === "sw") return "nesw-resize";
|
||||
if (h === "n" || h === "s") return "ns-resize";
|
||||
return "ew-resize";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// drawEditor
|
||||
//
|
||||
// Mental model: the output canvas (cropW × cropH) is pinned at (PAD, PAD).
|
||||
// The source image sits at (PAD + imgX, PAD + imgY) and can extend outside
|
||||
// the output in any direction. Areas inside the output not covered by the
|
||||
// image are transparent (shown as checkerboard).
|
||||
// ---------------------------------------------------------------------------
|
||||
function drawEditor(
|
||||
canvas: HTMLCanvasElement,
|
||||
img: HTMLImageElement,
|
||||
imgW: number, imgH: number,
|
||||
cropW: number, cropH: number,
|
||||
imgX: number, imgY: number,
|
||||
) {
|
||||
const cW = Math.max(cropW, imgW) + 2 * PAD;
|
||||
const cH = Math.max(cropH, imgH) + 2 * PAD;
|
||||
if (canvas.width !== cW || canvas.height !== cH) {
|
||||
canvas.width = cW;
|
||||
canvas.height = cH;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
|
||||
// 1. Checkerboard background — use cached tile to avoid createElement every frame
|
||||
ctx.fillStyle = ctx.createPattern(checkerTile(), "repeat")!;
|
||||
ctx.fillRect(0, 0, cW, cH);
|
||||
|
||||
// 2. Faint image boundary indicator (dashed border around full source image)
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.2)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(PAD + imgX + 0.5, PAD + imgY + 0.5, imgW - 1, imgH - 1);
|
||||
ctx.setLineDash([]);
|
||||
ctx.restore();
|
||||
|
||||
// 3. Draw source image
|
||||
ctx.drawImage(img, PAD + imgX, PAD + imgY);
|
||||
|
||||
// 4. Dark overlay outside the output rect
|
||||
const ox0 = PAD, oy0 = PAD, ox1 = PAD + cropW, oy1 = PAD + cropH;
|
||||
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
||||
ctx.fillRect(0, 0, cW, oy0); // top
|
||||
ctx.fillRect(0, oy1, cW, cH - oy1); // bottom
|
||||
ctx.fillRect(0, oy0, ox0, oy1 - oy0); // left
|
||||
ctx.fillRect(ox1, oy0, cW - ox1, oy1 - oy0); // right
|
||||
|
||||
// 5. Rule-of-thirds grid
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
||||
ctx.lineWidth = 0.75;
|
||||
for (const t of [1 / 3, 2 / 3]) {
|
||||
ctx.beginPath(); ctx.moveTo(ox0 + cropW * t, oy0); ctx.lineTo(ox0 + cropW * t, oy1); ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(ox0, oy0 + cropH * t); ctx.lineTo(ox1, oy0 + cropH * t); ctx.stroke();
|
||||
}
|
||||
|
||||
// 6. Output rect border
|
||||
ctx.strokeStyle = "#fff";
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.strokeRect(ox0 + 0.5, oy0 + 0.5, cropW - 1, cropH - 1);
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 7. Resize handles
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.strokeStyle = "#555";
|
||||
ctx.lineWidth = 1;
|
||||
for (const [, hx, hy] of getHandlePoints(ox0, oy0, ox1, oy1)) {
|
||||
ctx.beginPath();
|
||||
ctx.rect(hx - 4, hy - 4, 8, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CropTool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CropTool() {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imgW, setImgW] = useState(0);
|
||||
const [imgH, setImgH] = useState(0);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
// Output canvas dimensions
|
||||
const [cropW, setCropW] = useState(128);
|
||||
const [cropH, setCropH] = useState(128);
|
||||
|
||||
// Where the source image's top-left sits within the output canvas.
|
||||
const [imgX, setImgX] = useState(0);
|
||||
const [imgY, setImgY] = useState(0);
|
||||
|
||||
// Padding used by "Fit to Content"
|
||||
const [padding, setPadding] = useState(0);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const sourceCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
// Direct canvas preview — avoids toDataURL on every drag frame
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const imgElRef = useRef<HTMLImageElement | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
|
||||
// Always-fresh values for use inside stable callbacks
|
||||
const outputRef = useRef({ w: cropW, h: cropH, imgX, imgY, padding });
|
||||
useEffect(() => { outputRef.current = { w: cropW, h: cropH, imgX, imgY, padding }; });
|
||||
|
||||
// ── Load image onto hidden source canvas ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!imageSrc) return;
|
||||
setImageReady(false);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const src = sourceCanvasRef.current!;
|
||||
src.width = img.naturalWidth;
|
||||
src.height = img.naturalHeight;
|
||||
src.getContext("2d")!.drawImage(img, 0, 0);
|
||||
imgElRef.current = img;
|
||||
setImgW(img.naturalWidth);
|
||||
setImgH(img.naturalHeight);
|
||||
setCropW(img.naturalWidth);
|
||||
setCropH(img.naturalHeight);
|
||||
setImgX(0);
|
||||
setImgY(0);
|
||||
setImageReady(true);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// ── Redraw editor whenever state changes ─────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!imageReady || !displayCanvasRef.current || !imgElRef.current) return;
|
||||
drawEditor(displayCanvasRef.current, imgElRef.current, imgW, imgH, cropW, cropH, imgX, imgY);
|
||||
}, [imageReady, imgW, imgH, cropW, cropH, imgX, imgY]);
|
||||
|
||||
// ── Live preview — draw directly into previewCanvasRef (no toDataURL) ────────
|
||||
useEffect(() => {
|
||||
if (!imageReady || !previewCanvasRef.current) return;
|
||||
const src = sourceCanvasRef.current!;
|
||||
const prev = previewCanvasRef.current;
|
||||
prev.width = Math.max(1, Math.round(cropW));
|
||||
prev.height = Math.max(1, Math.round(cropH));
|
||||
// Clear to transparent, then composite the cropped region
|
||||
const ctx = prev.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, prev.width, prev.height);
|
||||
ctx.drawImage(src, Math.round(imgX), Math.round(imgY));
|
||||
}, [imageReady, cropW, cropH, imgX, imgY]);
|
||||
|
||||
// ── Auto-center ──────────────────────────────────────────────────────────────
|
||||
const autoCenter = useCallback(() => {
|
||||
if (!imageReady) return;
|
||||
const src = sourceCanvasRef.current!;
|
||||
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||
|
||||
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX === -1) return;
|
||||
|
||||
const contentCX = (minX + maxX) / 2;
|
||||
const contentCY = (minY + maxY) / 2;
|
||||
const { w, h } = outputRef.current;
|
||||
setImgX(Math.round(w / 2 - contentCX));
|
||||
setImgY(Math.round(h / 2 - contentCY));
|
||||
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Fit to content ───────────────────────────────────────────────────────────
|
||||
const fitToContent = useCallback(() => {
|
||||
if (!imageReady) return;
|
||||
const src = sourceCanvasRef.current!;
|
||||
const { data, width, height } = src.getContext("2d")!.getImageData(0, 0, src.width, src.height);
|
||||
|
||||
let minX = width, minY = height, maxX = -1, maxY = -1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (data[(y * width + x) * 4 + 3] > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX === -1) return;
|
||||
|
||||
const pad = outputRef.current.padding;
|
||||
const contentW = maxX - minX + 1;
|
||||
const contentH = maxY - minY + 1;
|
||||
setCropW(contentW + 2 * pad);
|
||||
setCropH(contentH + 2 * pad);
|
||||
setImgX(pad - minX);
|
||||
setImgY(pad - minY);
|
||||
}, [imageReady]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── File loading ─────────────────────────────────────────────────────────────
|
||||
const loadFile = useCallback((file: File) => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(URL.createObjectURL(file));
|
||||
setImageFile(file);
|
||||
}, [imageSrc]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setImageFile(null);
|
||||
setImageReady(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [imageSrc]);
|
||||
|
||||
// Download: toBlob from previewCanvasRef — only at explicit user request
|
||||
const handleDownload = () => {
|
||||
const prev = previewCanvasRef.current;
|
||||
if (!prev || !imageReady || !imageFile) return;
|
||||
prev.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_cropped.png";
|
||||
a.href = url;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
// ── Canvas interaction ───────────────────────────────────────────────────────
|
||||
const getCanvasXY = (e: React.MouseEvent<HTMLCanvasElement>): [number, number] => {
|
||||
const canvas = displayCanvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return [
|
||||
(e.clientX - rect.left) * (canvas.width / rect.width),
|
||||
(e.clientY - rect.top) * (canvas.height / rect.height),
|
||||
];
|
||||
};
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const [px, py] = getCanvasXY(e);
|
||||
const { w, h, imgX: ix, imgY: iy } = outputRef.current;
|
||||
const handle = hitHandle(px, py, PAD, PAD, PAD + w, PAD + h);
|
||||
if (!handle) return;
|
||||
dragStateRef.current = {
|
||||
handle,
|
||||
startX: px, startY: py,
|
||||
origW: w, origH: h,
|
||||
origImgX: ix, origImgY: iy,
|
||||
};
|
||||
if (displayCanvasRef.current) {
|
||||
displayCanvasRef.current.style.cursor = handleCursor(handle, true);
|
||||
}
|
||||
e.preventDefault();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = displayCanvasRef.current;
|
||||
if (!canvas) return;
|
||||
const [px, py] = getCanvasXY(e);
|
||||
|
||||
if (!dragStateRef.current) {
|
||||
const { w, h } = outputRef.current;
|
||||
canvas.style.cursor = handleCursor(hitHandle(px, py, PAD, PAD, PAD + w, PAD + h));
|
||||
return;
|
||||
}
|
||||
|
||||
const { handle, startX, startY, origW, origH, origImgX, origImgY } = dragStateRef.current;
|
||||
const dx = Math.round(px - startX);
|
||||
const dy = Math.round(py - startY);
|
||||
|
||||
let nw = origW, nh = origH, nix = origImgX, niy = origImgY;
|
||||
|
||||
if (handle === "pan") {
|
||||
nix = origImgX + dx;
|
||||
niy = origImgY + dy;
|
||||
} else {
|
||||
if (handle.includes("e")) nw = Math.max(MIN_CROP, origW + dx);
|
||||
if (handle.includes("s")) nh = Math.max(MIN_CROP, origH + dy);
|
||||
if (handle.includes("w")) {
|
||||
const d = Math.min(dx, origW - MIN_CROP);
|
||||
nw = origW - d;
|
||||
nix = origImgX - d;
|
||||
}
|
||||
if (handle.includes("n")) {
|
||||
const d = Math.min(dy, origH - MIN_CROP);
|
||||
nh = origH - d;
|
||||
niy = origImgY - d;
|
||||
}
|
||||
}
|
||||
|
||||
setCropW(nw);
|
||||
setCropH(nh);
|
||||
setImgX(nix);
|
||||
setImgY(niy);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
dragStateRef.current = null;
|
||||
if (displayCanvasRef.current) displayCanvasRef.current.style.cursor = "default";
|
||||
}, []);
|
||||
|
||||
const parseIntSafe = (v: string, fallback: number) => {
|
||||
const n = parseInt(v, 10);
|
||||
return isNaN(n) ? fallback : n;
|
||||
};
|
||||
|
||||
// ── Upload screen ────────────────────────────────────────────────────────────
|
||||
if (!imageSrc) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-1">Crop Tool</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upload an image, then define an output canvas. Drag inside the canvas
|
||||
to pan the image within it, or drag the edge handles to resize. The
|
||||
output canvas can extend beyond the image to add transparent padding.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f?.type.startsWith("image/")) loadFile(f);
|
||||
}}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||
)}
|
||||
>
|
||||
<Upload className={cn("w-10 h-10 transition-colors", dragOver ? "text-primary" : "text-text-tertiary")} />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editor screen ────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
<canvas ref={sourceCanvasRef} className="hidden" />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground flex-1">Crop Tool</h2>
|
||||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||
{imageFile?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||
"hover:text-destructive hover:border-destructive transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!imageReady}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||
imageReady
|
||||
? "bg-primary text-white hover:bg-primary/90"
|
||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="flex flex-wrap gap-4 items-end">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary block">Output W (px)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_CROP}
|
||||
value={cropW}
|
||||
onChange={(e) => setCropW(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||
className={cn(
|
||||
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary block">Output H (px)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={MIN_CROP}
|
||||
value={cropH}
|
||||
onChange={(e) => setCropH(Math.max(MIN_CROP, parseIntSafe(e.target.value, MIN_CROP)))}
|
||||
className={cn(
|
||||
"w-24 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary block">Image X</span>
|
||||
<input
|
||||
type="number"
|
||||
value={imgX}
|
||||
onChange={(e) => setImgX(parseIntSafe(e.target.value, 0))}
|
||||
className={cn(
|
||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary block">Image Y</span>
|
||||
<input
|
||||
type="number"
|
||||
value={imgY}
|
||||
onChange={(e) => setImgY(parseIntSafe(e.target.value, 0))}
|
||||
className={cn(
|
||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-xs text-text-tertiary self-center font-mono pb-1">
|
||||
src: {imgW} × {imgH}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-border">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs font-medium text-text-secondary block">Padding (px)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={padding}
|
||||
onChange={(e) => setPadding(Math.max(0, parseIntSafe(e.target.value, 0)))}
|
||||
className={cn(
|
||||
"w-20 bg-input border border-border rounded-md px-2 py-1.5 text-sm text-foreground font-mono",
|
||||
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex gap-2 self-end pb-0.5">
|
||||
<button
|
||||
onClick={autoCenter}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||
)}
|
||||
title="Pan the image so non-transparent content is centered within the current output canvas"
|
||||
>
|
||||
<Crosshair className="w-3.5 h-3.5" /> Auto-center
|
||||
</button>
|
||||
<button
|
||||
onClick={fitToContent}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium transition-colors",
|
||||
"bg-input text-text-secondary hover:text-primary hover:border-primary/60",
|
||||
)}
|
||||
title="Resize output to the non-transparent content bounding box + padding, then center"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" /> Fit to Content
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-tertiary self-end pb-1 ml-auto">
|
||||
Drag inside the canvas to pan · drag handles to resize
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor + Preview */}
|
||||
<div className="grid grid-cols-[1fr_260px] gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Editor
|
||||
</p>
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg overflow-auto"
|
||||
style={{ maxHeight: "62vh" }}
|
||||
>
|
||||
<canvas
|
||||
ref={displayCanvasRef}
|
||||
className="block"
|
||||
style={{ maxWidth: "100%" }}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Preview
|
||||
</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{imageReady ? (
|
||||
<div style={CHECKERBOARD}>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
className="w-full block"
|
||||
style={{ imageRendering: (cropW < 64 || cropH < 64) ? "pixelated" : "auto" }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-square flex items-center justify-center">
|
||||
<ImageIcon className="w-10 h-10 text-text-tertiary opacity-20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{imageReady && (
|
||||
<p className="text-xs text-text-tertiary text-center font-mono">
|
||||
{Math.round(cropW)} × {Math.round(cropH)} px
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Upload, Download, X, ImageIcon } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CHECKERBOARD: React.CSSProperties = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #444 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #444 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #444 75%)
|
||||
`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
||||
backgroundColor: "#2a2a2a",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebGL — shaders + init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VERT = `
|
||||
attribute vec2 aPos;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vUV = aPos * 0.5 + 0.5;
|
||||
vUV.y = 1.0 - vUV.y;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}`;
|
||||
|
||||
// RGB↔HSL math in GLSL — runs massively parallel on GPU
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uImage;
|
||||
uniform float uHueShift;
|
||||
uniform float uSaturation;
|
||||
uniform float uLightness;
|
||||
varying vec2 vUV;
|
||||
|
||||
vec3 rgb2hsl(vec3 c) {
|
||||
float maxC = max(c.r, max(c.g, c.b));
|
||||
float minC = min(c.r, min(c.g, c.b));
|
||||
float l = (maxC + minC) * 0.5;
|
||||
float d = maxC - minC;
|
||||
if (d < 0.001) return vec3(0.0, 0.0, l);
|
||||
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
|
||||
float h;
|
||||
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
||||
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
|
||||
else h = (c.r - c.g) / d + 4.0;
|
||||
return vec3(h / 6.0, s, l);
|
||||
}
|
||||
|
||||
float hue2rgb(float p, float q, float t) {
|
||||
if (t < 0.0) t += 1.0;
|
||||
if (t > 1.0) t -= 1.0;
|
||||
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
||||
if (t < 0.5) return q;
|
||||
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
||||
return p;
|
||||
}
|
||||
|
||||
vec3 hsl2rgb(vec3 hsl) {
|
||||
float h = hsl.x, s = hsl.y, l = hsl.z;
|
||||
if (s < 0.001) return vec3(l);
|
||||
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
||||
float p = 2.0 * l - q;
|
||||
return vec3(
|
||||
hue2rgb(p, q, h + 1.0 / 3.0),
|
||||
hue2rgb(p, q, h),
|
||||
hue2rgb(p, q, h - 1.0 / 3.0)
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 c = texture2D(uImage, vUV);
|
||||
if (c.a < 0.001) { gl_FragColor = c; return; }
|
||||
vec3 hsl = rgb2hsl(c.rgb);
|
||||
hsl.x = fract(hsl.x + uHueShift + 1.0);
|
||||
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
|
||||
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
|
||||
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
|
||||
}`;
|
||||
|
||||
type GlState = {
|
||||
gl: WebGLRenderingContext;
|
||||
tex: WebGLTexture;
|
||||
uHueShift: WebGLUniformLocation;
|
||||
uSaturation: WebGLUniformLocation;
|
||||
uLightness: WebGLUniformLocation;
|
||||
};
|
||||
|
||||
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
||||
const gl = canvas.getContext("webgl", {
|
||||
premultipliedAlpha: false,
|
||||
preserveDrawingBuffer: true,
|
||||
}) as WebGLRenderingContext | null;
|
||||
if (!gl) return null;
|
||||
|
||||
const vert = gl.createShader(gl.VERTEX_SHADER)!;
|
||||
gl.shaderSource(vert, VERT);
|
||||
gl.compileShader(vert);
|
||||
|
||||
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
|
||||
gl.shaderSource(frag, FRAG);
|
||||
gl.compileShader(frag);
|
||||
|
||||
const prog = gl.createProgram()!;
|
||||
gl.attachShader(prog, vert);
|
||||
gl.attachShader(prog, frag);
|
||||
gl.linkProgram(prog);
|
||||
gl.useProgram(prog);
|
||||
|
||||
const buf = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
const aPos = gl.getAttribLocation(prog, "aPos");
|
||||
gl.enableVertexAttribArray(aPos);
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const tex = gl.createTexture()!;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
|
||||
return {
|
||||
gl,
|
||||
tex,
|
||||
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
|
||||
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
|
||||
uLightness: gl.getUniformLocation(prog, "uLightness")!,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Slider({
|
||||
label, value, min, max, unit, onChange, gradient,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
unit: string;
|
||||
onChange: (v: number) => void;
|
||||
gradient?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-baseline">
|
||||
<p className="text-xs font-medium text-text-secondary">{label}</p>
|
||||
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
|
||||
{`${value > 0 ? "+" : ""}${value}${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative h-5 flex items-center">
|
||||
{gradient && (
|
||||
<div
|
||||
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
|
||||
style={{ background: gradient }}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className={cn(
|
||||
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
|
||||
"[&::-webkit-slider-thumb]:appearance-none",
|
||||
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
|
||||
"[&::-webkit-slider-thumb]:-mt-1",
|
||||
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
|
||||
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
|
||||
"[&::-webkit-slider-thumb]:shadow-sm",
|
||||
gradient
|
||||
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
|
||||
: "accent-primary",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HueShifter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HueShifter() {
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imageReady, setImageReady] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
const [hueShift, setHueShift] = useState(0);
|
||||
const [saturation, setSaturation] = useState(0);
|
||||
const [lightness, setLightness] = useState(0);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// glCanvasRef — WebGL result canvas (also the preview)
|
||||
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const glRef = useRef<GlState | null>(null);
|
||||
|
||||
// ── Load image → upload texture once ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!imageSrc) return;
|
||||
setImageReady(false);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const glCanvas = glCanvasRef.current;
|
||||
if (!glCanvas) return;
|
||||
glCanvas.width = img.naturalWidth;
|
||||
glCanvas.height = img.naturalHeight;
|
||||
|
||||
if (!glRef.current) {
|
||||
glRef.current = initGl(glCanvas);
|
||||
if (!glRef.current) {
|
||||
console.error("HueShifter: WebGL not supported");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { gl, tex } = glRef.current;
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex);
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
||||
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
||||
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
||||
|
||||
setImageReady(true);
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc]);
|
||||
|
||||
// ── Re-render on GPU whenever sliders change ───────────────────────────────
|
||||
useEffect(() => {
|
||||
const state = glRef.current;
|
||||
if (!state || !imageReady) return;
|
||||
|
||||
const { gl, uHueShift, uSaturation, uLightness } = state;
|
||||
gl.uniform1f(uHueShift, hueShift / 360);
|
||||
gl.uniform1f(uSaturation, saturation / 100);
|
||||
gl.uniform1f(uLightness, lightness / 100);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
}, [imageReady, hueShift, saturation, lightness]);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
const loadFile = useCallback((file: File) => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(URL.createObjectURL(file));
|
||||
setImageFile(file);
|
||||
setHueShift(0);
|
||||
setSaturation(0);
|
||||
setLightness(0);
|
||||
}, [imageSrc]);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
||||
setImageSrc(null);
|
||||
setImageFile(null);
|
||||
setImageReady(false);
|
||||
glRef.current = null;
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [imageSrc]);
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith("image/")) loadFile(file);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const glCanvas = glCanvasRef.current;
|
||||
if (!glCanvas || !imageFile || !imageReady) return;
|
||||
glCanvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
|
||||
a.href = url;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setHueShift(0);
|
||||
setSaturation(0);
|
||||
setLightness(0);
|
||||
};
|
||||
|
||||
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
|
||||
|
||||
// ── Upload screen ──────────────────────────────────────────────────────────
|
||||
if (!imageSrc) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-1">Hue Shifter</h2>
|
||||
<p className="text-sm text-text-secondary">
|
||||
Upload an image and shift its hue, saturation, and lightness to create colour
|
||||
variants. Fully transparent pixels are preserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
||||
dragOver
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
||||
)}
|
||||
>
|
||||
<Upload
|
||||
className={cn(
|
||||
"w-10 h-10 transition-colors",
|
||||
dragOver ? "text-primary" : "text-text-tertiary",
|
||||
)}
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-text-secondary">
|
||||
{dragOver ? "Drop to upload" : "Drop an image here"}
|
||||
</p>
|
||||
<p className="text-xs text-text-tertiary mt-1">
|
||||
or click to browse · PNG, JPEG, WebP · max 15 MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Editor screen ──────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-4 pb-8">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
|
||||
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
||||
{imageFile?.name}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isDefault}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
|
||||
isDefault
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:text-foreground hover:border-primary/40",
|
||||
)}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
||||
"hover:text-destructive hover:border-destructive transition-colors",
|
||||
)}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!imageReady || isDefault}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
||||
imageReady && !isDefault
|
||||
? "bg-primary text-white hover:bg-primary/90"
|
||||
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" /> Download PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
|
||||
<Slider
|
||||
label="Hue Shift"
|
||||
value={hueShift}
|
||||
min={-180}
|
||||
max={180}
|
||||
unit="°"
|
||||
onChange={setHueShift}
|
||||
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
|
||||
/>
|
||||
<Slider
|
||||
label="Saturation"
|
||||
value={saturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={setSaturation}
|
||||
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
|
||||
/>
|
||||
<Slider
|
||||
label="Lightness"
|
||||
value={lightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
unit="%"
|
||||
onChange={setLightness}
|
||||
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Side-by-side */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Original
|
||||
</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
<div style={CHECKERBOARD}>
|
||||
<img src={imageSrc} alt="Original" className="w-full block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
Result
|
||||
</p>
|
||||
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
||||
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
||||
<div style={CHECKERBOARD}>
|
||||
<canvas
|
||||
ref={glCanvasRef}
|
||||
className={cn("w-full block", !imageReady && "hidden")}
|
||||
/>
|
||||
{!imageReady && (
|
||||
<div className="aspect-square flex items-center justify-center">
|
||||
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1588
panel/src/pages/ItemStudio.tsx
Normal file
1588
panel/src/pages/ItemStudio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,18 @@ import {
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
Scissors,
|
||||
Crop,
|
||||
Palette,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useItems, type Item } from "../lib/useItems";
|
||||
import { ItemStudio } from "./ItemStudio";
|
||||
import { BackgroundRemoval } from "./BackgroundRemoval";
|
||||
import { CropTool } from "./CropTool";
|
||||
import { HueShifter } from "./HueShifter";
|
||||
import { CanvasTool } from "./CanvasTool";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -26,7 +35,7 @@ const RARITY_COLORS: Record<string, string> = {
|
||||
SSR: "bg-amber-500/20 text-amber-400",
|
||||
};
|
||||
|
||||
type Tab = "all" | "studio";
|
||||
type Tab = "all" | "studio" | "bgremoval" | "crop" | "hue" | "canvas";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SearchFilterBar
|
||||
@@ -383,6 +392,7 @@ export default function Items() {
|
||||
setPage,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useItems();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||
@@ -436,6 +446,54 @@ export default function Items() {
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Item Studio
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("bgremoval")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "bgremoval"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Scissors className="w-4 h-4" />
|
||||
Background Removal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("crop")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "crop"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Crop className="w-4 h-4" />
|
||||
Crop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("hue")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "hue"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
Hue Shifter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("canvas")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
|
||||
activeTab === "canvas"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-text-tertiary hover:text-foreground hover:border-primary/30"
|
||||
)}
|
||||
>
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
Canvas
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -475,17 +533,21 @@ export default function Items() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === "studio" ? (
|
||||
<ItemStudio
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
setActiveTab("all");
|
||||
}}
|
||||
/>
|
||||
) : activeTab === "bgremoval" ? (
|
||||
<BackgroundRemoval />
|
||||
) : activeTab === "crop" ? (
|
||||
<CropTool />
|
||||
) : activeTab === "hue" ? (
|
||||
<HueShifter />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<Sparkles className="w-16 h-16 text-text-tertiary mb-4" />
|
||||
<h2 className="text-lg font-semibold text-text-secondary mb-2">
|
||||
Item Studio
|
||||
</h2>
|
||||
<p className="text-sm text-text-tertiary max-w-md">
|
||||
AI-assisted item editor coming soon. Create and customize items with
|
||||
generated icons, balanced stats, and lore descriptions.
|
||||
</p>
|
||||
</div>
|
||||
<CanvasTool />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user