diff --git a/README.md b/README.md index 1d435df..0b8e8c1 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Update them any time with `sudo ./setup.sh configure`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `sunshine` | -| `utilities` | `actualbudget`, `ai-gpu`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `utilities` | `actualbudget`, `ai-gpu`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/paintplus/.env.example b/paintplus/.env.example new file mode 100644 index 0000000..836db6f --- /dev/null +++ b/paintplus/.env.example @@ -0,0 +1,207 @@ +# ============================================================================= +# AI Photo Edit - Environment Configuration +# ============================================================================= +# +# SETUP INSTRUCTIONS: +# 1. Copy this file to .env: cp .env.example .env +# 2. Get API key from Replicate (see below) +# 3. Paste your key in the REPLICATE_API_KEY line +# 4. Rebuild: docker-compose up -d --build +# +# ============================================================================= + + +# ============================================================================= +# STEP 1: Choose AI Provider +# ============================================================================= +# Options: local_gpu, mock, openai, stability, replicate, invokeai, comfyui +# +# local_gpu = FREE, runs on YOUR GPU — best option if you have an NVIDIA card +# (use docker-compose.gpu.yml — models auto-download on first use) +# mock = Free, returns original image unchanged (UI testing only) +# openai = DALL-E 3 / gpt-image-1 (~$0.02-0.04/image) +# stability = Stability AI SDXL (~$0.01/image) +# replicate = Multiple models (~$0.002-0.03/image) +# invokeai = Self-hosted InvokeAI running on another machine +# comfyui = Self-hosted ComfyUI running on another machine +# +# GPU QUICK-START: +# docker compose -f docker-compose.gpu.yml up --build +# (AI_PROVIDER defaults to local_gpu in that compose file) +# ============================================================================= + +AI_PROVIDER=replicate + +# ── Local GPU settings (only relevant when AI_PROVIDER=local_gpu) ──────────── +# Auto-download HuggingFace models on first request (true/false) +AUTO_DOWNLOAD_MODELS=true +# Max diffusion pipelines to keep loaded in GPU memory (each is 2–7 GB) +LOCAL_GPU_MAX_PIPELINES=2 +# HuggingFace token — only needed for gated/private models +#HF_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Override auto-selected model for any operation (leave blank = auto by VRAM tier) +#HF_MODEL_INPAINT=your-org/your-inpaint-model +#HF_MODEL_TXT2IMG=your-org/your-txt2img-model +#HF_MODEL_IMG2IMG=your-org/your-img2img-model +# ───────────────────────────────────────────────────────────────────────────── + +# Per-operation provider overrides (optional — blank means use AI_PROVIDER above) +# Example: use OpenAI for text-to-image (best quality) but InvokeAI for everything else +#AI_PROVIDER_TXT2IMG=openai +#AI_PROVIDER_INPAINT=invokeai +#AI_PROVIDER_IMG2IMG=invokeai +#AI_PROVIDER_OUTPAINT=invokeai + + +# ============================================================================= +# STEP 2: Get Your API Key +# ============================================================================= +# +# ╔═══════════════════════════════════════════════════════════════════════════╗ +# ║ REPLICATE (RECOMMENDED) ║ +# ╠═══════════════════════════════════════════════════════════════════════════╣ +# ║ ║ +# ║ 1. Go to: https://replicate.com ║ +# ║ 2. Click "Sign in" (use GitHub, Google, or email) ║ +# ║ 3. Go to: https://replicate.com/account/api-tokens ║ +# ║ 4. Click "Create token" ║ +# ║ 5. Copy the token (starts with "r8_") ║ +# ║ 6. Paste it below after REPLICATE_API_KEY= ║ +# ║ ║ +# ║ FREE TIER: New accounts get some free credits to try models! ║ +# ║ PRICING: ~$0.002-0.03 per image depending on model ║ +# ║ ║ +# ╚═══════════════════════════════════════════════════════════════════════════╝ + +REPLICATE_API_KEY=r8_PASTE_YOUR_KEY_HERE + +# ─────────────────────────────────────────────────────────────────────────── +# OPENAI (cloud, dall-e-3 / gpt-image-1) +# Get key at: https://platform.openai.com/api-keys +# AI_PROVIDER=openai +# ─────────────────────────────────────────────────────────────────────────── +#OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +#OPENAI_MODEL=dall-e-3 + +# ─────────────────────────────────────────────────────────────────────────── +# INVOKEAI (self-hosted, best for Flux/SDXL) +# Run InvokeAI on your local machine or NAS, point URL here. +# AI_PROVIDER=invokeai +# ─────────────────────────────────────────────────────────────────────────── +#INVOKEAI_URL=http://192.168.1.x:9090 +#INVOKEAI_DEFAULT_MODEL=flux-dev + +# ─────────────────────────────────────────────────────────────────────────── +# COMFYUI (self-hosted, workflow JSON API) +# AI_PROVIDER=comfyui +# ─────────────────────────────────────────────────────────────────────────── +#COMFYUI_URL=http://192.168.1.x:8188 +#COMFYUI_DEFAULT_MODEL=v1-5-pruned-emaonly.ckpt + +# ─────────────────────────────────────────────────────────────────────────── +# STABILITY AI (Alternative) +# Get key at: https://platform.stability.ai/account/keys +# ─────────────────────────────────────────────────────────────────────────── +#STABILITY_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + +# ============================================================================= +# STEP 3: Model Selection (OPTIONAL - for advanced users) +# ============================================================================= +# +# By default, the system AUTO-SELECTS the best model based on your prompt: +# - Prompt contains "remove/erase/delete" → Uses LaMa (fast removal) +# - Prompt contains "face/hands/person" → Uses Realistic Vision +# - Everything else → Uses SDXL Inpaint +# +# To FORCE a specific model, uncomment ONE line below: +# ─────────────────────────────────────────────────────────────────────────── + +# REPLICATE_MODEL=sdxl-inpaint # General purpose, good quality (~$0.01) +# REPLICATE_MODEL=lama # Object removal ONLY (~$0.002, fastest) +# REPLICATE_MODEL=realistic-vision # Faces, hands, skin (~$0.02) + +# ─────────────────────────────────────────────────────────────────────────── +# IMPORTANT: About Flux and other text-to-image models +# ─────────────────────────────────────────────────────────────────────────── +# +# Models like "black-forest-labs/flux-kontext-pro" are TEXT-TO-IMAGE models. +# They generate NEW images from text, they DON'T edit existing images. +# +# For EDITING (inpainting), you need models that accept: +# - An existing image +# - A mask showing what to change +# - A prompt describing the change +# +# WORKS for editing: DOESN'T work for editing: +# ✓ sdxl-inpaint ✗ flux-kontext-pro (text-to-image) +# ✓ lama ✗ flux-dev (text-to-image) +# ✓ realistic-vision ✗ ideogram (text-to-image) +# +# ─────────────────────────────────────────────────────────────────────────── + +# Stability AI model selection (if using AI_PROVIDER=stability) +#STABILITY_MODEL=sdxl # Options: sdxl, sd15, sd21 + + +# ============================================================================= +# SECURITY (Change this in production!) +# ============================================================================= + +SECRET_KEY=change-this-to-a-long-random-string-in-production + + +# ============================================================================= +# ADVANCED SETTINGS (Usually don't need to change) +# ============================================================================= + +# CORS origins (comma-separated) +CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost:3080,http://localhost + +# Database path +DATABASE_URL=sqlite:///./data/ai_photo_edit.db + +# Auto-download SAM model on startup (true/false) +# When true (default): Downloads SAM model (~375MB) on first startup for offline Smart Select +# When false: Skips download, Smart Select uses Replicate API (requires REPLICATE_API_KEY) +AUTO_DOWNLOAD_SAM=true + +# Auto-download U2Net model on startup (true/false) +# When true (default): Downloads U2Net model (~176MB) on first startup for offline Remove Background +# When false: Skips download, Remove Background falls back to rembg (if installed) +AUTO_DOWNLOAD_U2NET=true + +# Background removal model (Remove Background tool) — used when request.model="auto" +# Options: ben2 (default — best for clean cutouts, hair/edges), birefnet-hr +# (best for high-res/print work, slower), u2net (lightweight, always-on fallback) +# ben2 and birefnet-hr download weights from HuggingFace on first use (GPU image only). +BG_REMOVAL_MODEL=ben2 + +# Allow users to select model per-edit +ALLOW_MODEL_OVERRIDE=true + + +# ============================================================================= +# TROUBLESHOOTING +# ============================================================================= +# +# PROBLEM: "405 Method Not Allowed" errors +# FIX: Rebuild container: docker-compose build --no-cache && docker-compose up -d +# +# PROBLEM: "REPLICATE_API_KEY not configured" +# FIX: 1. Make sure .env file exists (not just .env.example) +# 2. Make sure REPLICATE_API_KEY has your actual key +# 3. Restart: docker-compose down && docker-compose up -d +# +# PROBLEM: Edits don't change the image +# FIX: Check AI_PROVIDER isn't set to "mock" +# +# PROBLEM: "rembg not installed" +# FIX: Rebuild: docker-compose build --no-cache backend +# +# PROBLEM: Smart Select uses flood-fill instead of AI +# FIX: Smart Select needs REPLICATE_API_KEY for SAM model +# +# CHECK LOGS: docker-compose logs -f backend +# +# ============================================================================= diff --git a/paintplus/.gitignore b/paintplus/.gitignore new file mode 100644 index 0000000..7e7cac6 --- /dev/null +++ b/paintplus/.gitignore @@ -0,0 +1,70 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +venv/ +env/ +ENV/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnp +.pnp.js +coverage/ +build/ +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment +.env + +# Data +data/projects/*/ +!data/projects/.gitkeep +*.db +*.sqlite +*.sqlite3 + +# Docker +*.log +docker-compose.override.yml + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# OS +.DS_Store +Thumbs.db diff --git a/paintplus/CONTRIBUTING.md b/paintplus/CONTRIBUTING.md new file mode 100644 index 0000000..1d715ef --- /dev/null +++ b/paintplus/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing to AI Photo Edit + +Thank you for your interest in contributing to AI Photo Edit! + +## Development Setup + +1. Fork the repository +2. Clone your fork +3. Create a feature branch +4. Make your changes +5. Test your changes +6. Submit a pull request + +## Development Environment + +### Using Docker (Recommended) + +```bash +# Start dev environment with hot-reload +docker-compose -f docker-compose.dev.yml up +``` + +### Local Development + +**Backend** +```bash +cd backend +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +**Frontend** +```bash +cd frontend +npm install +npm run dev +``` + +## Code Style + +### Python (Backend) +- Follow PEP 8 +- Use type hints where appropriate +- Add docstrings to functions and classes + +### JavaScript/React (Frontend) +- Use functional components with hooks +- Follow React best practices +- Use meaningful variable names + +## Pull Request Process + +1. Update the README.md with details of changes if needed +2. Ensure all tests pass +3. Update documentation as needed +4. Get approval from maintainers +5. Squash commits if requested + +## Reporting Bugs + +When reporting bugs, please include: +- Description of the issue +- Steps to reproduce +- Expected behavior +- Actual behavior +- Screenshots if applicable +- Environment details (OS, Docker version, etc.) + +## Feature Requests + +We welcome feature requests! Please: +- Check if the feature already exists +- Explain the use case +- Describe the expected behavior +- Consider if it aligns with project goals + +## Code of Conduct + +- Be respectful and inclusive +- Welcome newcomers +- Focus on constructive feedback +- Respect differing opinions + +Thank you for contributing! diff --git a/paintplus/Caddyfile b/paintplus/Caddyfile new file mode 100644 index 0000000..10c40c7 --- /dev/null +++ b/paintplus/Caddyfile @@ -0,0 +1,122 @@ +# Caddy 2 Configuration for EditmaskwithAI +# ========================================== +# +# SETUP INSTRUCTIONS: +# 1. Replace 'your-subdomain.yourdomain.com' with your actual domain +# 2. Make sure DNS CNAME record points to your server +# 3. Ensure ports 80 and 443 are open (Caddy handles SSL automatically) +# 4. The frontend runs on port 3080 by default (docker-compose) +# +# Common Issues: +# - "Connection refused": Check if the frontend container is running +# - "Bad gateway": Check if localhost:3080 is accessible +# - "SSL error": Make sure ports 80/443 are open for Let's Encrypt + +# ============================================ +# OPTION 1: Domain with automatic HTTPS (recommended) +# ============================================ +# Replace with your actual domain +your-subdomain.yourdomain.com { + # Reverse proxy to frontend (nginx serves both frontend and proxies API) + reverse_proxy localhost:3080 { + # Health checks + health_uri /health + health_interval 30s + health_timeout 10s + + # Headers for proper proxying + header_up Host {upstream_hostport} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } + + # Enable compression + encode gzip zstd + + # Logging (optional - uncomment for debugging) + # log { + # output file /var/log/caddy/access.log + # format json + # } +} + +# ============================================ +# OPTION 2: IP address or localhost (no HTTPS) +# ============================================ +# Uncomment this block and comment out Option 1 if you don't have a domain +# or want to test locally + +# :8080 { +# reverse_proxy localhost:3080 { +# header_up Host {upstream_hostport} +# header_up X-Real-IP {remote_host} +# header_up X-Forwarded-For {remote_host} +# } +# encode gzip zstd +# } + +# ============================================ +# OPTION 3: Multiple subdomains +# ============================================ +# If you want both www and non-www versions + +# yourdomain.com, www.yourdomain.com { +# reverse_proxy localhost:3080 { +# header_up Host {upstream_hostport} +# header_up X-Real-IP {remote_host} +# header_up X-Forwarded-For {remote_host} +# header_up X-Forwarded-Proto {scheme} +# } +# encode gzip zstd +# } + +# ============================================ +# OPTION 4: Behind another reverse proxy (Cloudflare, etc.) +# ============================================ +# Use this if Caddy is behind Cloudflare or another proxy + +# your-subdomain.yourdomain.com { +# # Trust proxy headers from upstream +# servers { +# trusted_proxies static 173.245.48.0/20 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 141.101.64.0/18 108.162.192.0/18 190.93.240.0/20 188.114.96.0/20 197.234.240.0/22 198.41.128.0/17 162.158.0.0/15 104.16.0.0/13 104.24.0.0/14 172.64.0.0/13 131.0.72.0/22 +# } +# +# reverse_proxy localhost:3080 { +# header_up Host {upstream_hostport} +# header_up X-Real-IP {http.request.header.CF-Connecting-IP} +# header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} +# header_up X-Forwarded-Proto {scheme} +# } +# encode gzip zstd +# } + +# ============================================ +# TROUBLESHOOTING +# ============================================ +# +# 1. Check Caddy logs: +# docker logs caddy +# OR: journalctl -u caddy -f +# +# 2. Test backend connectivity: +# curl -I http://localhost:3080 +# +# 3. Check DNS resolution: +# dig your-subdomain.yourdomain.com +# nslookup your-subdomain.yourdomain.com +# +# 4. Verify ports are open: +# sudo netstat -tlnp | grep -E ':(80|443|3080)' +# +# 5. Check firewall: +# sudo ufw status +# sudo iptables -L -n +# +# 6. For Let's Encrypt issues: +# - Ensure ports 80 and 443 are accessible from internet +# - Check if domain resolves to your server's IP +# - Try: caddy validate --config /path/to/Caddyfile +# +# 7. Force reload Caddy config: +# caddy reload --config /path/to/Caddyfile diff --git a/paintplus/Dockerfile b/paintplus/Dockerfile new file mode 100644 index 0000000..660a7f0 --- /dev/null +++ b/paintplus/Dockerfile @@ -0,0 +1,68 @@ +# ============================================================================== +# AI Photo Edit - Unified Container +# Builds miniPaint frontend and serves it alongside FastAPI backend +# ============================================================================== + +# Stage 1: Build miniPaint frontend +FROM node:20-alpine AS frontend-build + +WORKDIR /frontend + +# Copy package files and install dependencies +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install + +# Copy frontend source and build +COPY frontend/ ./ +RUN npm run build + +# Stage 2: Python backend with frontend static files +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies for OpenCV, rembg, SAM, and image processing +# Note: onnxruntime 1.17+ fixed executable stack issues, no longer need execstack +RUN apt-get update && apt-get install -y \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + wget \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements and install Python dependencies +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Verify rembg loads correctly (model downloads on first use) +# rembg now supports BiRefNet models which are state-of-the-art for background removal +RUN python -c "from rembg import remove; print('rembg ready')" || echo "WARNING: rembg not available - Remove Background will be disabled" + +# Copy backend application +COPY backend/ . + +# Copy entrypoint script +COPY backend/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copy scripts +COPY scripts/ /scripts/ + +# Copy miniPaint frontend files from stage 1 +COPY --from=frontend-build /frontend/index.html /app/static/ +COPY --from=frontend-build /frontend/dist /app/static/dist +COPY --from=frontend-build /frontend/images /app/static/images +COPY --from=frontend-build /frontend/src/css /app/static/src/css + +# Create data directories +RUN mkdir -p /app/data/projects /app/data/patches /app/data/models + +# Expose port +EXPOSE 8000 + +# Use entrypoint script +ENTRYPOINT ["/entrypoint.sh"] diff --git a/paintplus/Dockerfile.gpu b/paintplus/Dockerfile.gpu new file mode 100644 index 0000000..b922cef --- /dev/null +++ b/paintplus/Dockerfile.gpu @@ -0,0 +1,82 @@ +# ============================================================================= +# EditmaskwithAI — GPU Container (NVIDIA CUDA) +# +# Usage: +# docker compose -f docker-compose.gpu.yml up --build +# +# Requirements on host: +# - NVIDIA driver ≥ 525 (for CUDA 12.x) +# - nvidia-container-toolkit installed and configured +# - docker compose v2 (or docker-compose with GPU device support) +# +# AMD ROCm users: replace the pytorch base image with a ROCm variant, e.g. +# rocm/pytorch:latest (and remove the nvidia-smi check below) +# ============================================================================= + +# ── Stage 1: Build miniPaint frontend ──────────────────────────────────────── +FROM node:20-alpine AS frontend-build + +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: PyTorch CUDA runtime ──────────────────────────────────────────── +# pytorch/pytorch already includes torch + torchvision built for CUDA 12.1. +# Using the runtime (not devel) image keeps the layer lean. +FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime + +WORKDIR /app + +# System dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + wget \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies — base + GPU extras +# BUILDID forces pip layers to re-run when you need fresh packages without a full --no-cache: +# BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build +ARG BUILDID=1 +COPY backend/requirements.txt . +COPY backend/requirements.gpu.txt . +RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.txt +RUN echo "BUILDID=$BUILDID" && pip install --no-cache-dir -r requirements.gpu.txt + +# Smoke-test rembg (model downloads on first use) +RUN python -c "from rembg import remove; print('rembg OK')" \ + || echo "WARNING: rembg unavailable — Remove Background disabled" + +# Smoke-test ben2 (weights download from HuggingFace on first use) +RUN python -c "import ben2; print('ben2 OK')" \ + || echo "WARNING: ben2 unavailable — Remove Background falls back to U2Net/rembg" + +# Copy backend application +COPY backend/ . + +# Entrypoint +COPY backend/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Scripts (SAM download, DB init, GPU setup, etc.) +COPY scripts/ /scripts/ +RUN chmod +x /scripts/*.py 2>/dev/null || true + +# Copy built frontend from Stage 1 +COPY --from=frontend-build /frontend/index.html /app/static/ +COPY --from=frontend-build /frontend/dist /app/static/dist +COPY --from=frontend-build /frontend/images /app/static/images +COPY --from=frontend-build /frontend/src/css /app/static/src/css + +# Persistent data directories +RUN mkdir -p /app/data/projects /app/data/patches /app/data/models + +EXPOSE 8000 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/paintplus/LICENSE b/paintplus/LICENSE new file mode 100644 index 0000000..cbc2ce1 --- /dev/null +++ b/paintplus/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 AI Photo Edit Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/paintplus/Makefile b/paintplus/Makefile new file mode 100644 index 0000000..6467ed0 --- /dev/null +++ b/paintplus/Makefile @@ -0,0 +1,35 @@ +.PHONY: help up down build logs clean dev test + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +up: ## Start the application (production) + docker-compose up -d + +down: ## Stop the application + docker-compose down + +build: ## Build all containers + docker-compose build + +logs: ## Show logs + docker-compose logs -f + +clean: ## Remove all containers, volumes, and data + docker-compose down -v + rm -rf data/ + +dev: ## Start the application (development mode) + docker-compose -f docker-compose.dev.yml up + +test: ## Run tests + @echo "Tests not yet implemented" + +restart: ## Restart the application + docker-compose restart + +ps: ## Show running containers + docker-compose ps diff --git a/paintplus/README.md b/paintplus/README.md new file mode 100644 index 0000000..8033ed7 --- /dev/null +++ b/paintplus/README.md @@ -0,0 +1,292 @@ +# PaintPlus + +_Vendored into ubuntu-post-install as the `paintplus` service. Based on EditmaskwithAI (github.com/outis1one/EditmaskwithAI)._ + +A self-hosted, web-based AI photo editor. Paint over any object, describe what you want, and the AI replaces just that region — every pixel outside your selection stays untouched. + +## Quick Start + +### GPU machine (recommended — free inference, best quality) + +```bash +git clone https://github.com/outis1one/editmaskwithai +cd editmaskwithai + +# One-time setup: installs nvidia-container-toolkit, configures Docker, +# sets up a permanent DNS fix, and prefetches all AI models on the host. +chmod +x install-local-gpu.sh +./install-local-gpu.sh + +# Start the app (run this each time): +chmod +x bring-up-local-gpu.sh +./bring-up-local-gpu.sh +``` + +Open **http://localhost:3080** + +**Models (~13 GB total, one time) download automatically on the host**, outside Docker — both scripts call `./prefetch-models.sh` for you, since in-container DNS is unreliable on some hosts. They're cached in `./data/hf_cache/` and `./data/models/`, and survive rebuilds. + +--- + +### Cloud API (no GPU required) + +```bash +git clone https://github.com/outis1one/editmaskwithai +cd editmaskwithai +cp .env.example .env +# Edit .env: set AI_PROVIDER and your API key (see .env.example for options) +docker compose up -d --build +``` + +Open **http://localhost:3080** + +--- + +### Updates (any machine) + +```bash +git pull +# GPU: +./bring-up-local-gpu.sh +# or cloud (no GPU): +docker compose up -d --build +``` + +If pip packages seem stale after a pull (e.g., wrong diffusers version), force a pip layer rebuild without re-downloading the entire PyTorch base image: + +```bash +BUILDID=$(date +%s) ./bring-up-local-gpu.sh +``` + +--- + +## AI Providers + +| Provider | Setup | Cost | Quality | +|---|---|---|---| +| `local_gpu` | GPU machine + nvidia-container-toolkit | Free | Best (SDXL/FLUX auto-selected by VRAM) | +| `openai` | `OPENAI_API_KEY=sk-...` | ~$0.02–0.04/image | DALL-E 3 | +| `replicate` | `REPLICATE_API_KEY=r8_...` | ~$0.002–0.03/image | Multiple models | +| `invokeai` | InvokeAI running on another machine | Self-hosted | FLUX/SDXL | +| `comfyui` | ComfyUI running on another machine | Self-hosted | Any model | + +You can also mix: set a default provider in `.env` and override per-operation in the **Image → AI Provider Settings** dialog inside the app. + +--- + +## GPU Tier Auto-Selection + +The app detects your GPU at startup and picks the best model it can run: + +| Effective VRAM | Model selected | Notes | +|---|---|---| +| ≥ 24 GB | FLUX.1-schnell | Best quality, 4-step generation | +| 12–24 GB | SDXL | Excellent quality | +| 8–12 GB | SDXL + xformers | Good quality | +| 6–8 GB | SDXL + attention slicing | Good quality, slightly slower | +| 4–6 GB | SDXL + CPU offload | Good quality, slower (GTX 1060 6GB range) | +| 2–4 GB | SD 1.5 | Fast, lower detail | +| < 2 GB | SD 1.5 + CPU offload | Very slow — consider a cloud provider | + +Override the auto-selected model with `HF_MODEL_TXT2IMG`, `HF_MODEL_INPAINT` in `.env`. + +--- + +## What it can do + +### Selection +- **Smart Select (SAM brush)** — paint over an object, AI detects its exact boundaries +- **Smart Select (click)** — click any object, SAM selects it +- **Rectangle / Ellipse / Lasso** — classic selection tools + +### After selecting +- **AI Edit** — describe what to change ("add a scar", "make it look aged") +- **Make less symmetrical** — AI adds natural organic variation +- **Replace with clipboard** — paste any image into the selection shape +- **Scale by %** — make the selected object bigger/smaller, AI fills the gap +- **Copy / Cut to layer** — non-destructive layer workflow +- **Erase** — remove the selected region with AI fill + +### Image tools +- **Text → Image** — generate from a text description (GPU or cloud) +- **Upscale** — Real-ESRGAN AI upscaling (genuinely adds detail, not just resize) +- **Prepare for Print** — one-click: AI upscale to target DPI + fit to frame +- **Fit to Frame** — resize/crop/AI-extend to standard print sizes +- **Expand Canvas (Outpaint)** — AI extends the image in any direction +- **Remove Background** — one-click background removal (BEN2 by default, BiRefNet-HR or U2Net selectable) + +### Print presets +Frame sizes: 4×6, 5×7, 8×10, 11×14, 16×20, 18×24, 20×24, 24×36 (portrait + landscape) +DPI options: 72, 150, 200, 300 — 200 DPI is fine for 18×24" and larger (viewed from distance) + +--- + +## Progress bars + +All AI operations show a real-time progress overlay. For local GPU inference, the bar advances step-by-step as the model denoises (e.g. "Step 14 / 30"). For cloud providers and upscale operations, it animates to indicate activity. + +--- + +## Logs + +```bash +# GPU container: +docker compose -f docker-compose.gpu.yml logs -f + +# Standard container: +docker compose logs -f +``` + +--- + +## File structure + +``` +EditmaskwithAI/ +├── backend/ +│ ├── app/ +│ │ ├── routers/ # API endpoints (ai_tools, print_tools, …) +│ │ ├── services/ # gpu_detect, local_diffusion, upscale, … +│ │ └── config.py +│ ├── requirements.txt +│ └── requirements.gpu.txt +├── frontend/ +│ └── src/js/ +│ ├── tools/ # brush_select (SAM paint), smart_select, … +│ ├── modules/ +│ │ ├── generate/ # text_to_image, outpaint +│ │ └── image/ # upscale, frame_fit, print_prepare, … +│ └── libs/ +│ └── progress_overlay.js +├── docker-compose.yml # Cloud / no-GPU +├── docker-compose.gpu.yml # NVIDIA GPU (recommended) +├── docker-compose.dev.yml # Dev with hot reload +├── Dockerfile +├── Dockerfile.gpu +├── install-local-gpu.sh # One-time GPU host setup +├── bring-up-local-gpu.sh # Start/stop the GPU container +├── prefetch-models.sh # Download AI models on the host (called automatically; also runnable standalone) +└── .env.example +``` + +--- + +## Troubleshooting + +**GPU not detected in Docker** +```bash +# Check toolkit is installed and Docker restarted: +docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi +# If that fails, re-run: sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker +``` + +**Model download stalls or fails** +```bash +# Check logs for HuggingFace errors: +docker compose -f docker-compose.gpu.yml logs -f | grep -E "local_gpu|Error|Failed" +# If a private/gated model: add HF_TOKEN=hf_... to .env +``` + +**SAM model fails to download (DNS error / firewall blocking port 53)** + +If the container can't reach `dl.fbaipublicfiles.com` (you'll see `Errno -3 Name or service not known` in the logs), download SAM directly on the host and let the bind mount make it visible to the container — no rebuild needed: + +```bash +./prefetch-models.sh +# or manually: +mkdir -p ./data/models +# sudo needed if ./data/ was created by Docker (root-owned): +sudo curl -L -o ./data/models/sam_vit_b_01ec64.pth \ + https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth +``` + +The file is ~375 MB. Once it exists at `./data/models/sam_vit_b_01ec64.pth`, the container picks it up on the next startup (no rebuild required). Verify with: +```bash +docker compose -f docker-compose.gpu.yml logs | grep -i sam +# Should show: "SAM model loaded on cuda" (or cpu) +``` + +If Docker created `./data/` as root and you can't write there without `sudo`, you can also use root's curl as above — the container reads the file regardless of owner. + +**Remove Background fails ("Install ben2, u2net, or rembg")** + +Remove Background tries, in order: the model set by `BG_REMOVAL_MODEL` (default `ben2`), then the other local models, then `rembg` as a last resort. You'll see this error only if all of them fail. + +- **ben2 / birefnet-hr** (GPU image only) download their weights from HuggingFace on first use, cached under `./data/hf_cache`. If that download fails (DNS/firewall, see above), run `./prefetch-models.sh` to fetch both directly on the host, or check the logs for the specific error: + ```bash + docker compose -f docker-compose.gpu.yml logs -f | grep -iE "ben2|birefnet" + ``` +- **u2net** auto-downloads (~176MB) from GitHub on first use, same as SAM. If that fails too, download it directly on the host: + ```bash + ./prefetch-models.sh + # or manually: + mkdir -p ./data/models + sudo curl -L -o ./data/models/u2net.onnx \ + https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx + ``` + The file is ~176 MB. Once it exists at `./data/models/u2net.onnx`, the next "Remove Background" click picks it up — no rebuild or restart needed. Verify with: + ```bash + docker compose logs -f | grep -i u2net + # Should show: "U2Net model loaded successfully with OpenCV DNN" + ``` + +You can also pick a specific model per-edit from the Remove Background dialog's model dropdown, overriding `BG_REMOVAL_MODEL` for that one call. + +**AI models not downloading (container DNS blocked)** + +`install-local-gpu.sh` and `bring-up-local-gpu.sh` already run this for you automatically on every start, so you normally don't need to think about it. If a model still didn't download (no network at the time, etc.), re-run it manually — it lands in `./data/`, which is already bind-mounted into the container, so it's picked up with no rebuild: + +```bash +./prefetch-models.sh # SAM + U2Net + BEN2 + BiRefNet-HR (~1.5GB) +./prefetch-models.sh --sdxl # also Text→Image / AI Edit models (~13GB) +./bring-up-local-gpu.sh +``` + +If that also fails to reach the network, the problem is host-level (firewall/DNS), not Docker-specific — see your network/firewall configuration. + +Alternatively, if you ran `./install-local-gpu.sh`, container DNS is already permanently fixed via a systemd-managed iptables rule. If you skipped that script, apply the same fix manually (does **not** affect container isolation): + +```bash +sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT +./bring-up-local-gpu.sh +``` + +The container will now resolve hostnames and download models automatically (~13 GB on first run, then cached). Watch progress: +```bash +docker compose -f docker-compose.gpu.yml logs -f | grep -E "local_gpu|Cached|failed" +``` + +**Alternative: download with a Docker helper container** (no host Python needed): + +```bash +# Inpainting model (~6.5 GB) — needed for AI Edit, Make less symmetrical, etc. +docker run --rm \ + -v "$(pwd)/data/hf_cache:/root/.cache/huggingface" \ + python:3.11-slim \ + bash -c "pip install -q huggingface-hub && \ + huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 \ + --exclude '*.msgpack' 'flax_*' 'tf_*'" + +# Text-to-image model (~6.5 GB) — needed for Text → Image +docker run --rm \ + -v "$(pwd)/data/hf_cache:/root/.cache/huggingface" \ + python:3.11-slim \ + bash -c "pip install -q huggingface-hub && \ + huggingface-cli download stabilityai/stable-diffusion-xl-base-1.0 \ + --exclude '*.msgpack' 'flax_*' 'tf_*'" +``` + +Then restart: `docker compose -f docker-compose.gpu.yml restart` + +**Out of VRAM during generation** +- Reduce `LOCAL_GPU_MAX_PIPELINES=1` in `.env` (default 2) +- Or override to a smaller model: `HF_MODEL_TXT2IMG=runwayml/stable-diffusion-v1-5` + +**Settings saved locally only** +- The in-app AI Provider Settings dialog saves to localStorage for the session +- To make settings permanent: edit `.env` and rebuild + +**Check API docs** +``` +http://localhost:3080/api/docs +``` diff --git a/paintplus/backend/.env.example b/paintplus/backend/.env.example new file mode 100644 index 0000000..2b27e7c --- /dev/null +++ b/paintplus/backend/.env.example @@ -0,0 +1,21 @@ +# Database +DATABASE_URL=sqlite:///./data/ai_photo_edit.db + +# Security +SECRET_KEY=your-secret-key-here-change-in-production +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# AI Provider (configure based on your provider) +AI_PROVIDER=openai +OPENAI_API_KEY=your-openai-api-key-here +# Alternative providers (uncomment as needed) +# AI_PROVIDER=stability +# STABILITY_API_KEY=your-stability-api-key-here + +# File Storage +DATA_DIR=/app/data +MAX_UPLOAD_SIZE_MB=50 + +# CORS +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 diff --git a/paintplus/backend/Dockerfile b/paintplus/backend/Dockerfile new file mode 100644 index 0000000..3bfd49c --- /dev/null +++ b/paintplus/backend/Dockerfile @@ -0,0 +1,37 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies for OpenCV, SAM, and image processing +RUN apt-get update && apt-get install -y \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ + libgomp1 \ + wget \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Copy entrypoint script and make it executable +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Create data directories +RUN mkdir -p /app/data/projects /app/data/patches /app/data/models + +# Expose port +EXPOSE 8000 + +# Use entrypoint script (auto-populates eyes on first run, then starts server) +ENTRYPOINT ["/entrypoint.sh"] diff --git a/paintplus/backend/app/__init__.py b/paintplus/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paintplus/backend/app/config.py b/paintplus/backend/app/config.py new file mode 100644 index 0000000..55b6d46 --- /dev/null +++ b/paintplus/backend/app/config.py @@ -0,0 +1,79 @@ +from pydantic_settings import BaseSettings +from typing import List + + +class Settings(BaseSettings): + # Database + database_url: str = "sqlite:///./data/ai_photo_edit.db" + + # Security + secret_key: str = "your-secret-key-change-in-production" + algorithm: str = "HS256" + access_token_expire_minutes: int = 30 + + # AI Provider + # Local: blank or "mock" — always available, no config needed + # Remote default (used for any operation without a specific override): + # openai | invokeai | comfyui | replicate | stability + ai_provider: str = "mock" + + # Per-operation provider overrides — blank means use ai_provider default. + # Operations: inpaint, txt2img, img2img, outpaint + # Example: AI_PROVIDER_TXT2IMG=openai (use OpenAI for text-to-image only) + ai_provider_inpaint: str = "" # remote inpaint / replace selection + ai_provider_txt2img: str = "" # text-to-image + ai_provider_img2img: str = "" # image-to-image + ai_provider_outpaint: str = "" # expand canvas + + # Provider API Keys + openai_api_key: str = "" + openai_model: str = "dall-e-3" + stability_api_key: str = "" + replicate_api_key: str = "" + + # InvokeAI (self-hosted) + invokeai_url: str = "" + invokeai_default_model: str = "flux-dev" + + # ComfyUI (self-hosted) + comfyui_url: str = "" + comfyui_default_model: str = "v1-5-pruned-emaonly.ckpt" + + # Model Selection (optional, provider-specific) + stability_model: str = "sdxl" # Options: sdxl, sd15, sd21 + replicate_model: str = "sdxl-inpaint" # Options: sdxl-inpaint, lama, realistic-vision + + # Allow per-edit model override + allow_model_override: bool = True + + # Remove Background — preferred local model when request.model="auto" + # Options: ben2 (default, best for clean cutouts/hair), birefnet-hr (best + # for high-res/print work), u2net (lightweight, smallest download) + bg_removal_model: str = "ben2" + + # Local GPU diffusion (AI_PROVIDER=local_gpu) + auto_download_models: bool = True # download HF models on first use + local_gpu_max_pipelines: int = 2 # max diffusion pipelines kept in GPU memory + hf_token: str = "" # HuggingFace token (only needed for gated models) + # Override auto-selected models per operation (leave blank = auto-pick by VRAM tier) + hf_model_inpaint: str = "" + hf_model_txt2img: str = "" + hf_model_img2img: str = "" + + # File Storage + data_dir: str = "./data" + max_upload_size_mb: int = 50 + + # CORS + cors_origins: str = "http://localhost:3000,http://localhost:5173" + + @property + def cors_origins_list(self) -> List[str]: + return [origin.strip() for origin in self.cors_origins.split(",")] + + class Config: + env_file = ".env" + case_sensitive = False + + +settings = Settings() diff --git a/paintplus/backend/app/database.py b/paintplus/backend/app/database.py new file mode 100644 index 0000000..1256bfb --- /dev/null +++ b/paintplus/backend/app/database.py @@ -0,0 +1,30 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from app.config import settings +import os + +# Ensure data directory exists +os.makedirs("./data", exist_ok=True) + +engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False} # Needed for SQLite +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Initialize database tables""" + Base.metadata.create_all(bind=engine) diff --git a/paintplus/backend/app/main.py b/paintplus/backend/app/main.py new file mode 100644 index 0000000..542e3ff --- /dev/null +++ b/paintplus/backend/app/main.py @@ -0,0 +1,162 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, HTMLResponse +from contextlib import asynccontextmanager +from pathlib import Path +import asyncio +import os + +from app.config import settings +from app.database import init_db +from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools +from app.routers import gpu_status + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Initialize database on startup; auto-install Real-ESRGAN NCNN in background.""" + init_db() + # Kick off NCNN install in background if no AI upscaler detected + from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed + caps = probe_upscale_capabilities() + if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]: + asyncio.create_task(ensure_ncnn_installed()) + # Pre-download SAM model in background so first click is fast + from app.services.sam_service import ensure_sam_installed + asyncio.create_task(ensure_sam_installed()) + + # If local GPU provider is active, log GPU info at startup + if settings.ai_provider.lower() == "local_gpu" or any( + v.lower() == "local_gpu" + for v in [ + settings.ai_provider_inpaint, + settings.ai_provider_txt2img, + settings.ai_provider_img2img, + settings.ai_provider_outpaint, + ] + if v + ): + from app.services.gpu_detect import get_cached_gpu_info + info = get_cached_gpu_info() + cc_str = f" | CC={info.compute_capability}" if info.compute_capability else "" + print( + f"[gpu] {info.device_name} | {info.vram_total_gb:.1f} GB{cc_str} | " + f"tier={info.tier} | fp16={info.fp16}" + ) + for w in info.warnings: + print(f"[gpu] ⚠ {w}") + if settings.auto_download_models: + # Download model weight files to disk cache in background so first + # user request loads from local disk instead of the internet. + from app.services.local_diffusion import prefetch_model_files + asyncio.create_task(prefetch_model_files()) + + yield + + +app = FastAPI( + title="AI Photo Edit API", + description="API for AI-powered photo editing with mask-scoped regeneration", + version="1.0.0", + lifespan=lifespan +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(projects.router) +app.include_router(edits.router) +app.include_router(images.router) +app.include_router(patches.router) +app.include_router(generate.router) +app.include_router(tools.router) +app.include_router(ai_tools.router) +app.include_router(print_tools.router) +app.include_router(gpu_status.router) + + +@app.get("/api") +def api_root(): + """API info endpoint""" + return { + "name": "AI Photo Edit API", + "version": "1.0.0", + "status": "running" + } + + +@app.get("/health") +def health(): + """Health check endpoint""" + return {"status": "healthy"} + + +# Static files directory +STATIC_DIR = Path("/app/static") + + +class NoCacheStaticFiles(StaticFiles): + """webpack outputs a fixed 'bundle.js' filename (no content hash), so + browsers can keep serving a stale cached copy after a rebuild unless + forced to revalidate on every request.""" + + def file_response(self, *args, **kwargs): + response = super().file_response(*args, **kwargs) + response.headers["Cache-Control"] = "no-cache" + return response + + +# Serve static assets - mount subdirectories if they exist +if STATIC_DIR.exists(): + # React-style assets folder + if (STATIC_DIR / "assets").exists(): + app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets") + # miniPaint dist folder (webpack bundle) - no-cache so code updates are picked up immediately + if (STATIC_DIR / "dist").exists(): + app.mount("/dist", NoCacheStaticFiles(directory=STATIC_DIR / "dist"), name="dist") + # miniPaint images folder + if (STATIC_DIR / "images").exists(): + app.mount("/images", StaticFiles(directory=STATIC_DIR / "images"), name="images") + # miniPaint CSS folder - no-cache, same reasoning as /dist + if (STATIC_DIR / "src").exists(): + app.mount("/src", NoCacheStaticFiles(directory=STATIC_DIR / "src"), name="src") + + +@app.get("/", response_class=HTMLResponse) +async def serve_spa(): + """Serve miniPaint index.html""" + index_path = STATIC_DIR / "index.html" + if index_path.exists(): + return FileResponse(index_path, headers={"Cache-Control": "no-cache"}) + return HTMLResponse("

Frontend not built. Run npm build in frontend/

") + + +@app.get("/{full_path:path}") +async def serve_spa_routes(request: Request, full_path: str): + """ + Catch-all route for serving static files. + Serves static files if they exist, otherwise returns index.html. + """ + # Don't catch API routes + if full_path.startswith(("projects", "edits", "patches", "tools", "generate", "health", "docs", "openapi.json", "api")): + return {"detail": "Not Found"} + + # Check if it's a static file + static_file = STATIC_DIR / full_path + if static_file.exists() and static_file.is_file(): + return FileResponse(static_file) + + # Otherwise serve index.html + index_path = STATIC_DIR / "index.html" + if index_path.exists(): + return FileResponse(index_path, headers={"Cache-Control": "no-cache"}) + + return HTMLResponse("

Frontend not built

", status_code=404) diff --git a/paintplus/backend/app/models/__init__.py b/paintplus/backend/app/models/__init__.py new file mode 100644 index 0000000..c9f1410 --- /dev/null +++ b/paintplus/backend/app/models/__init__.py @@ -0,0 +1,6 @@ +from app.models.user import User +from app.models.project import Project +from app.models.edit import Edit +from app.models.patch import Patch + +__all__ = ["User", "Project", "Edit", "Patch"] diff --git a/paintplus/backend/app/models/edit.py b/paintplus/backend/app/models/edit.py new file mode 100644 index 0000000..670850a --- /dev/null +++ b/paintplus/backend/app/models/edit.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Edit(Base): + __tablename__ = "edits" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + mode = Column(String, nullable=False) # "A" or "B" + prompt = Column(Text, nullable=False) + selection_type = Column(String, nullable=False) # "rectangle", "ellipse", "lasso" + bbox_json = Column(Text, nullable=False) # JSON string of {x, y, width, height} + feather_px = Column(Integer, default=0) + ai_provider = Column(String, nullable=False) + status = Column(String, nullable=False) # "pending", "processing", "completed", "failed" + error_message = Column(Text, nullable=True) + + # Relationships + project = relationship("Project", back_populates="edits") diff --git a/paintplus/backend/app/models/patch.py b/paintplus/backend/app/models/patch.py new file mode 100644 index 0000000..23f57fb --- /dev/null +++ b/paintplus/backend/app/models/patch.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Patch(Base): + __tablename__ = "patches" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + # Source information + source_type = Column(String, nullable=False) # "ai_generated", "manual_selection", "imported" + source_project_id = Column(Integer, ForeignKey("projects.id"), nullable=True) + source_edit_id = Column(Integer, ForeignKey("edits.id"), nullable=True) + + # Patch metadata + width = Column(Integer, nullable=False) + height = Column(Integer, nullable=False) + tags = Column(Text, nullable=True) # Comma-separated tags + category = Column(String, nullable=True) # "hand", "face", "body", "object", "texture", etc. + + # Is this patch shared/public? + is_public = Column(Boolean, default=False) + + # File path (relative to data dir) + file_path = Column(String, nullable=False) + thumbnail_path = Column(String, nullable=True) + + # Relationships + user = relationship("User", back_populates="patches") + source_project = relationship("Project") + source_edit = relationship("Edit") diff --git a/paintplus/backend/app/models/project.py b/paintplus/backend/app/models/project.py new file mode 100644 index 0000000..a76d79e --- /dev/null +++ b/paintplus/backend/app/models/project.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + name = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="projects") + edits = relationship("Edit", back_populates="project", cascade="all, delete-orphan") diff --git a/paintplus/backend/app/models/user.py b/paintplus/backend/app/models/user.py new file mode 100644 index 0000000..7c30d3e --- /dev/null +++ b/paintplus/backend/app/models/user.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + password_hash = Column(String, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + projects = relationship("Project", back_populates="user", cascade="all, delete-orphan") + patches = relationship("Patch", back_populates="user", cascade="all, delete-orphan") diff --git a/paintplus/backend/app/routers/__init__.py b/paintplus/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paintplus/backend/app/routers/ai_tools.py b/paintplus/backend/app/routers/ai_tools.py new file mode 100644 index 0000000..bec517c --- /dev/null +++ b/paintplus/backend/app/routers/ai_tools.py @@ -0,0 +1,989 @@ +""" +AI tools router — LaMa inpaint, background removal, remote generation, config. +All endpoints are under /api prefix. +""" + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional +import base64 +import asyncio +import json +from io import BytesIO + +from app.services.local_inpaint import ( + lama_inpaint, opencv_inpaint, lama_available, gpu_available, rembg_available, +) + +router = APIRouter(prefix="/api", tags=["ai-tools"]) + + +# ─── Request / response models ─────────────────────────────────────────────── + +class EraseRequest(BaseModel): + image: str # base64 + mask: str # base64 + + +class InpaintRemoteRequest(BaseModel): + image: str + mask: str + prompt: str + negative_prompt: Optional[str] = "" + steps: Optional[int] = 30 + cfg_scale: Optional[float] = 7.5 + model: Optional[str] = None + + +class Txt2ImgRequest(BaseModel): + prompt: str + width: Optional[int] = 1024 + height: Optional[int] = 1024 + negative_prompt: Optional[str] = "" + steps: Optional[int] = 30 + cfg_scale: Optional[float] = 7.5 + model: Optional[str] = None + seed: Optional[int] = 0 + + +class Img2ImgRequest(BaseModel): + image: str + prompt: str + strength: Optional[float] = 0.75 + negative_prompt: Optional[str] = "" + steps: Optional[int] = 30 + cfg_scale: Optional[float] = 7.5 + model: Optional[str] = None + + +class OutpaintRequest(BaseModel): + image: str + direction: str # left | right | top | bottom + size: Optional[int] = 256 + prompt: Optional[str] = "" + + +class BgRemoveRequest(BaseModel): + image: str + + +# ─── Helpers ───────────────────────────────────────────────────────────────── + +def _decode(b64: str) -> bytes: + return base64.b64decode(b64) + + +def _encode(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def _require_remote(operation: str = None): + from app.services.remote_provider import get_remote_provider + from app.config import settings + provider = get_remote_provider(operation) + if provider is None: + if (settings.ai_provider or "").lower() == "local_gpu": + raise HTTPException( + status_code=503, + detail=( + "local_gpu provider failed to load — diffusers may be incompatible with " + "the installed PyTorch version. Check container logs for details. " + "If you see 'torch has no attribute xpu', rebuild the container from the " + "correct branch so the pinned diffusers<0.29.0 is installed." + ) + ) + op_hint = f"AI_PROVIDER_{operation.upper()} or " if operation else "" + raise HTTPException( + status_code=503, + detail=f"No remote AI provider configured for '{operation or 'default'}'. " + f"Set {op_hint}AI_PROVIDER in .env (openai / invokeai / comfyui)." + ) + return provider + + +# ─── Local inpaint endpoints ───────────────────────────────────────────────── + +@router.post("/erase") +async def erase(req: EraseRequest): + """ + Magic eraser: remove object / fill region using LaMa (local, no API key needed). + Falls back to OpenCV if LaMa not installed. + """ + try: + image_bytes = _decode(req.image) + mask_bytes = _decode(req.mask) + + if lama_available(): + result = await asyncio.get_event_loop().run_in_executor( + None, lama_inpaint, image_bytes, mask_bytes + ) + method = "lama" + else: + result = await asyncio.get_event_loop().run_in_executor( + None, opencv_inpaint, image_bytes, mask_bytes + ) + method = "opencv" + + return {"result": _encode(result), "method": method} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/inpaint/lama") +async def inpaint_lama(req: EraseRequest): + """LaMa structural inpainting.""" + if not lama_available(): + raise HTTPException(status_code=503, detail="simple-lama-inpainting not installed.") + try: + result = await asyncio.get_event_loop().run_in_executor( + None, lama_inpaint, _decode(req.image), _decode(req.mask) + ) + return {"result": _encode(result)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/inpaint/fast") +async def inpaint_fast(req: EraseRequest): + """OpenCV fast inpainting (CPU, milliseconds).""" + try: + result = await asyncio.get_event_loop().run_in_executor( + None, opencv_inpaint, _decode(req.image), _decode(req.mask) + ) + return {"result": _encode(result)} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/background/remove") +async def background_remove(req: BgRemoveRequest): + """Remove background — rembg if available, else U2Net.""" + try: + image_bytes = _decode(req.image) + + # Try rembg first + if rembg_available(): + from app.services.local_inpaint import remove_background_rembg + result = await asyncio.get_event_loop().run_in_executor( + None, remove_background_rembg, image_bytes + ) + return {"result": _encode(result), "method": "rembg"} + + # Fall back to U2Net (existing implementation) + from PIL import Image + from io import BytesIO as _BytesIO + img = Image.open(_BytesIO(image_bytes)).convert("RGB") + from app.routers.tools import _remove_background_u2net + result = await _remove_background_u2net(img) + return {"result": _encode(result), "method": "u2net"} + + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +# ─── Remote provider endpoints ─────────────────────────────────────────────── + +@router.post("/inpaint/remote") +async def inpaint_remote(req: InpaintRemoteRequest): + """Inpaint via configured remote provider (InvokeAI / ComfyUI / OpenAI).""" + provider = _require_remote("inpaint") + try: + params = { + "negative_prompt": req.negative_prompt or "", + "steps": req.steps, + "cfg_scale": req.cfg_scale, + } + if req.model: + params["model"] = req.model + result = await provider.inpaint(_decode(req.image), _decode(req.mask), req.prompt, params) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/generate/progress") +async def generation_progress_stream(): + """ + SSE stream of local GPU pipeline inference progress. + Events are JSON arrays of pipeline state objects, emitted every 200 ms. + Each object: {pipeline, state, step, total_steps, progress, message, model_id, …} + Clients open this with EventSource before firing a generation POST, + then close it when the POST resolves. + """ + from app.services.local_diffusion import get_all_model_states + + async def event_gen(): + try: + while True: + states = get_all_model_states() + yield f"data: {json.dumps(states)}\n\n" + await asyncio.sleep(0.2) + except asyncio.CancelledError: + pass + + return StreamingResponse( + event_gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + +@router.post("/generate/txt2img") +async def txt2img(req: Txt2ImgRequest): + """Text-to-image via configured remote provider.""" + provider = _require_remote("txt2img") + try: + params = { + "negative_prompt": req.negative_prompt or "", + "steps": req.steps, + "cfg_scale": req.cfg_scale, + "seed": req.seed or 0, + } + if req.model: + params["model"] = req.model + result = await provider.txt2img(req.prompt, req.width, req.height, params) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/generate/img2img") +async def img2img(req: Img2ImgRequest): + """Image-to-image via configured remote provider.""" + provider = _require_remote("img2img") + try: + params = { + "negative_prompt": req.negative_prompt or "", + "steps": req.steps, + "cfg_scale": req.cfg_scale, + } + if req.model: + params["model"] = req.model + result = await provider.img2img(_decode(req.image), req.prompt, req.strength, params) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/generate/outpaint") +async def outpaint(req: OutpaintRequest): + """Expand canvas in given direction via remote provider.""" + provider = _require_remote("outpaint") + if req.direction not in ("left", "right", "top", "bottom"): + raise HTTPException(status_code=400, detail="direction must be left/right/top/bottom") + try: + result = await provider.outpaint(_decode(req.image), req.direction, req.size, req.prompt or "") + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +# ─── Config / capabilities ──────────────────────────────────────────────────── + +class ConfigUpdateRequest(BaseModel): + ai_provider: Optional[str] = None + # Per-operation overrides (blank = use default) + ai_provider_inpaint: Optional[str] = None + ai_provider_txt2img: Optional[str] = None + ai_provider_img2img: Optional[str] = None + ai_provider_outpaint: Optional[str] = None + # Credentials / URLs + openai_api_key: Optional[str] = None + openai_model: Optional[str] = None + invokeai_url: Optional[str] = None + invokeai_default_model: Optional[str] = None + comfyui_url: Optional[str] = None + comfyui_default_model: Optional[str] = None + replicate_api_key: Optional[str] = None + stability_api_key: Optional[str] = None + + +@router.post("/config") +async def update_config(req: ConfigUpdateRequest): + """ + Apply runtime provider settings (no restart needed). + Values are applied to the live settings object in-process. + They do NOT persist across restarts — set them in .env for permanence. + """ + from app.config import settings + + _str_fields = [ + "ai_provider", "ai_provider_inpaint", "ai_provider_txt2img", + "ai_provider_img2img", "ai_provider_outpaint", + "openai_api_key", "openai_model", + "invokeai_url", "invokeai_default_model", + "comfyui_url", "comfyui_default_model", + "replicate_api_key", "stability_api_key", + ] + for field in _str_fields: + val = getattr(req, field, None) + if val is not None: + setattr(settings, field, val) + + return { + "status": "ok", + "ai_provider": settings.ai_provider, + "overrides": { + "inpaint": settings.ai_provider_inpaint or None, + "txt2img": settings.ai_provider_txt2img or None, + "img2img": settings.ai_provider_img2img or None, + "outpaint": settings.ai_provider_outpaint or None, + } + } + + +async def _check_provider(operation: str) -> dict: + """Health-check the provider for a specific operation.""" + from app.services.remote_provider import get_remote_provider + try: + p = get_remote_provider(operation) + if p is None: + return {"provider": None, "healthy": False} + healthy = await asyncio.wait_for(p.health(), timeout=5.0) + return {"provider": p.__class__.__name__.replace("Provider", "").lower(), "healthy": healthy} + except Exception: + return {"provider": None, "healthy": False} + + +@router.get("/config") +async def get_config(): + """ + Return capability flags so the frontend can show/hide tools. + Includes per-operation provider assignments and health status. + """ + from app.config import settings + + # Run health checks for each operation concurrently + ops = ["inpaint", "txt2img", "img2img", "outpaint"] + results = await asyncio.gather(*[_check_provider(op) for op in ops]) + op_status = dict(zip(ops, results)) + + # Default provider for display (used when no per-op override) + default_name = (settings.ai_provider or "").lower() or None + + from app.services.gpu_detect import get_cached_gpu_info + gpu_info = get_cached_gpu_info() + + return { + "local": { + "lama": lama_available(), + "rembg": rembg_available(), + "opencv": True, + "gpu_detected": gpu_available(), + "gpu_backend": gpu_info.backend, + "gpu_device": gpu_info.device_name, + "gpu_vram_total": gpu_info.vram_total_gb, + "gpu_vram_free": gpu_info.vram_free_gb, + "gpu_cc": gpu_info.compute_capability, + "gpu_fp16": gpu_info.fp16, + "gpu_bf16": gpu_info.bf16, + "gpu_fp8": gpu_info.fp8, + "gpu_tensor_cores": gpu_info.tensor_cores, + "gpu_tier": gpu_info.tier, + "gpu_eff_vram": gpu_info.effective_vram_gb, + "local_gpu_available": gpu_info.backend in ("cuda", "mps"), + "local_gpu_capabilities": gpu_info.capabilities, + "local_gpu_warnings": gpu_info.warnings, + }, + "remote": { + "default_provider": default_name, + # Legacy field kept for backwards compat with badge/capabilities checks + "provider": default_name, + "healthy": any(v["healthy"] for v in op_status.values()), + "operations": op_status, + "overrides": { + "inpaint": settings.ai_provider_inpaint or None, + "txt2img": settings.ai_provider_txt2img or None, + "img2img": settings.ai_provider_img2img or None, + "outpaint": settings.ai_provider_outpaint or None, + }, + } + } + + +# ─── Selection image operations ───────────────────────────────────────────── + +class ScaleSelectionRequest(BaseModel): + image: str # base64 full canvas + mask: str # base64 selection mask (white = object) + scale_pct: float = 103.0 # 103 = 3% bigger, 95 = 5% smaller + + +class AiEditRegionRequest(BaseModel): + image: str + mask: str + instruction: str + negative_prompt: str = "" + steps: int = 30 + cfg_scale: float = 7.5 + + +class PasteIntoSelectionRequest(BaseModel): + image: str # base64 target canvas + mask: str # base64 selection mask + paste_image: str # base64 image to paste + + +@router.post("/image/scale-selection") +async def scale_selection(req: ScaleSelectionRequest): + """ + Scale the object selected by mask by scale_pct%, AI-fill the exposed gap. + Works purely with local tools (LaMa/OpenCV) — no remote provider needed. + """ + try: + import numpy as np + from PIL import Image, ImageFilter + except ImportError: + raise HTTPException(status_code=500, detail="PIL/numpy not available") + + img = Image.open(BytesIO(_decode(req.image))).convert("RGB") + mask = Image.open(BytesIO(_decode(req.mask))).convert("L") + if img.size != mask.size: + mask = mask.resize(img.size, Image.LANCZOS) + + mask_arr = np.array(mask) + ys, xs = np.where(mask_arr > 128) + if len(xs) == 0: + raise HTTPException(status_code=400, detail="Empty mask — nothing to scale") + + minx, maxx = int(xs.min()), int(xs.max()) + miny, maxy = int(ys.min()), int(ys.max()) + cx, cy = (minx + maxx) / 2.0, (miny + maxy) / 2.0 + obj_w, obj_h = maxx - minx + 1, maxy - miny + 1 + + scale = req.scale_pct / 100.0 + new_w = max(1, round(obj_w * scale)) + new_h = max(1, round(obj_h * scale)) + + # Extract masked object crop (RGBA with mask as alpha) + img_rgba = img.convert("RGBA") + obj_crop = img_rgba.crop((minx, miny, maxx + 1, maxy + 1)) + mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1)) + r, g, b, _ = obj_crop.split() + obj_masked = Image.merge("RGBA", (r, g, b, mask_crop)) + scaled_obj = obj_masked.resize((new_w, new_h), Image.LANCZOS) + + # AI-fill the original mask area (gap) with LaMa/OpenCV + gap_mask = mask.filter(ImageFilter.MaxFilter(9)) # expand ~4px for clean seam + gap_bytes = BytesIO() + img.save(gap_bytes, format="PNG") + gap_mask_bytes = BytesIO() + gap_mask.save(gap_mask_bytes, format="PNG") + + try: + if lama_available(): + filled_bytes = await asyncio.get_event_loop().run_in_executor( + None, lama_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue() + ) + else: + filled_bytes = await asyncio.get_event_loop().run_in_executor( + None, opencv_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue() + ) + filled = Image.open(BytesIO(filled_bytes)).convert("RGBA") + except Exception as exc: + print(f"[scale-selection] fill fallback: {exc}") + filled = img.convert("RGBA") + + # Paste scaled object centered on original centroid + px = round(cx - new_w / 2) + py = round(cy - new_h / 2) + result = filled.copy() + result.paste(scaled_obj, (px, py), scaled_obj.split()[3]) + + out = BytesIO() + result.convert("RGB").save(out, format="PNG") + return {"result": _encode(out.getvalue())} + + +@router.post("/image/ai-edit-region") +async def ai_edit_region(req: AiEditRegionRequest): + """ + AI-edit the selected region using the configured inpaint provider. + Works with local_gpu, InvokeAI, ComfyUI, or OpenAI. + """ + provider = _require_remote("inpaint") + try: + result_bytes = await provider.inpaint( + _decode(req.image), + _decode(req.mask), + req.instruction, + {"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale}, + ) + except Exception as exc: + import traceback; traceback.print_exc() + msg = str(exc) + if "Errno -3" in msg or "Name or service not known" in msg or "ConnectError" in msg: + raise HTTPException( + status_code=503, + detail=( + "AI model files not yet downloaded — container DNS appears to be blocked. " + "Fix: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT on the host, " + "or pre-download the model: pip install huggingface-hub && " + "huggingface-cli download diffusers/stable-diffusion-xl-1.0-inpainting-0.1 " + "--cache-dir ./data/hf_cache" + ) + ) + raise HTTPException(status_code=500, detail=msg) + return {"result": _encode(result_bytes)} + + +@router.post("/image/paste-into-selection") +async def paste_into_selection(req: PasteIntoSelectionRequest): + """ + Scale a clipboard image to the selection bounding box, mask it to the + selection shape, and composite it over the original canvas. + """ + try: + import numpy as np + from PIL import Image + except ImportError: + raise HTTPException(status_code=500, detail="PIL/numpy not available") + + img = Image.open(BytesIO(_decode(req.image))).convert("RGBA") + mask = Image.open(BytesIO(_decode(req.mask))).convert("L") + paste_img = Image.open(BytesIO(_decode(req.paste_image))).convert("RGBA") + + if img.size != mask.size: + mask = mask.resize(img.size, Image.LANCZOS) + + mask_arr = np.array(mask) + ys, xs = np.where(mask_arr > 128) + if len(xs) == 0: + raise HTTPException(status_code=400, detail="Empty mask") + + minx, maxx = int(xs.min()), int(xs.max()) + miny, maxy = int(ys.min()), int(ys.max()) + target_w = maxx - minx + 1 + target_h = maxy - miny + 1 + + # Scale clipboard image to fit the selection bounding box + paste_scaled = paste_img.resize((target_w, target_h), Image.LANCZOS) + + # Clip paste to selection shape using mask + mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1)) + r, g, b, a = paste_scaled.split() + mask_np = np.array(mask_crop) + alpha_np = np.array(a) + combined = (alpha_np.astype(np.uint16) * mask_np.astype(np.uint16) // 255).astype(np.uint8) + paste_final = Image.merge("RGBA", (r, g, b, Image.fromarray(combined))) + + result = img.copy() + result.paste(paste_final, (minx, miny), paste_final.split()[3]) + + out = BytesIO() + result.convert("RGB").save(out, format="PNG") + return {"result": _encode(out.getvalue())} + + +# ─── SAM (Segment Anything) ────────────────────────────────────────────────── + +class SegmentPointRequest(BaseModel): + image: str # base64 PNG/JPEG + points: list[list[int]] # [[x, y], ...] original image coords + labels: list[int] # 1=include, 0=exclude — same length as points + + +@router.post("/segment/point") +async def segment_point(req: SegmentPointRequest): + """ + Run SAM point-prompt segmentation. + Returns a binary mask PNG (white = selected area). + Auto-downloads the SAM ViT-B model (~375 MB) on first call. + """ + if not req.points: + raise HTTPException(status_code=400, detail="At least one point required.") + if len(req.points) != len(req.labels): + raise HTTPException(status_code=400, detail="points and labels must have the same length.") + + try: + image_bytes = base64.b64decode(req.image) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + from app.services.sam_service import predict_points, get_install_status + try: + mask_bytes = await predict_points( + image_bytes, + [tuple(p) for p in req.points], + req.labels, + ) + return { + "mask": base64.b64encode(mask_bytes).decode(), + "sam_install": get_install_status(), + } + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/segment/install-status") +def segment_install_status(): + """Poll SAM model download progress.""" + from app.services.sam_service import get_install_status, sam_model_available + status = get_install_status() + status["model_ready"] = sam_model_available() + return status + + +@router.post("/segment/install") +async def segment_install(): + """Trigger SAM model download explicitly (also auto-triggered on first /segment/point call).""" + from app.services.sam_service import ensure_sam_installed, get_install_status + asyncio.create_task(ensure_sam_installed()) + return get_install_status() + + +# ─── Enhance ───────────────────────────────────────────────────────────────── + +import io as _io +import numpy as _np +import cv2 as _cv2 +from PIL import Image as _Image + +class EnhanceRequest(BaseModel): + image: str # base64 + strength: float = 1.0 + + +def _enhance_image(image_bytes: bytes, strength: float) -> bytes: + """ + Apply a chain of non-AI image enhancements, each blended with `strength` (0–1). + + Steps: + 1. Auto white balance (gray-world) + 2. CLAHE on L channel of LAB colorspace + 3. Auto saturation boost in HSV (×1.15, clamped) + 4. Mild unsharp mask (gaussian sigma=1.0, delta weight=0.3) + """ + strength = max(0.0, min(1.0, float(strength))) + + # Decode to RGB numpy array + pil = _Image.open(_io.BytesIO(image_bytes)).convert("RGB") + orig = _np.array(pil, dtype=_np.float32) # H×W×3, float [0,255] + + img = orig.copy() + + # ── Step 1: Auto white balance (gray-world) ────────────────────────────── + mean_r = img[:, :, 0].mean() + mean_g = img[:, :, 1].mean() + mean_b = img[:, :, 2].mean() + overall_mean = (mean_r + mean_g + mean_b) / 3.0 + + def _scale(channel, channel_mean): + if channel_mean == 0: + return channel + return channel * (overall_mean / channel_mean) + + wb = img.copy() + wb[:, :, 0] = _np.clip(_scale(img[:, :, 0], mean_r), 0, 255) + wb[:, :, 1] = _np.clip(_scale(img[:, :, 1], mean_g), 0, 255) + wb[:, :, 2] = _np.clip(_scale(img[:, :, 2], mean_b), 0, 255) + + img = (orig + strength * (wb - orig)).clip(0, 255) + + # ── Step 2: CLAHE on L channel (LAB) ──────────────────────────────────── + img_u8 = img.astype(_np.uint8) + lab = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2LAB) + clahe = _cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + l_orig = lab[:, :, 0].copy() + lab[:, :, 0] = clahe.apply(l_orig) + # Blend L channel back using strength + lab_blended = lab.copy() + lab_blended[:, :, 0] = (l_orig + strength * (lab[:, :, 0].astype(_np.float32) - l_orig.astype(_np.float32))).clip(0, 255).astype(_np.uint8) + img = _cv2.cvtColor(lab_blended, _cv2.COLOR_LAB2RGB).astype(_np.float32) + + # ── Step 3: Auto saturation boost (HSV, ×1.15) ────────────────────────── + img_u8 = img.astype(_np.uint8) + hsv = _cv2.cvtColor(img_u8, _cv2.COLOR_RGB2HSV).astype(_np.float32) + s_orig = hsv[:, :, 1].copy() + s_boosted = _np.clip(s_orig * 1.15, 0, 255) + hsv[:, :, 1] = s_orig + strength * (s_boosted - s_orig) + hsv = hsv.clip(0, 255).astype(_np.uint8) + img = _cv2.cvtColor(hsv, _cv2.COLOR_HSV2RGB).astype(_np.float32) + + # ── Step 4: Mild unsharp mask (sigma=1.0, delta weight=0.3) ───────────── + img_u8 = img.astype(_np.uint8) + blurred = _cv2.GaussianBlur(img_u8, (0, 0), sigmaX=1.0) + sharpness_delta = img_u8.astype(_np.float32) - blurred.astype(_np.float32) + sharpened = img_u8.astype(_np.float32) + 0.3 * sharpness_delta * strength + img = sharpened.clip(0, 255) + + # Encode result as PNG + result_pil = _Image.fromarray(img.astype(_np.uint8), mode="RGB") + buf = _io.BytesIO() + result_pil.save(buf, format="PNG") + return buf.getvalue() + + +@router.post("/enhance") +async def enhance(req: EnhanceRequest): + """ + Non-AI image enhancement: auto white balance, CLAHE, saturation boost, + and unsharp mask. Each step is blended proportionally to `strength` (0–1). + """ + try: + image_bytes = _decode(req.image) + result = await asyncio.get_event_loop().run_in_executor( + None, _enhance_image, image_bytes, req.strength + ) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +# ─── Subject replace ───────────────────────────────────────────────────────── + +class ExtractSubjectRequest(BaseModel): + image: str # base64 + + +class ReplaceSubjectRequest(BaseModel): + background_image: str # base64 — image whose background we keep + subject_image: str # base64 — image whose subject we extract + mask: Optional[str] = None # base64 — white = where the subject should land + match_colors: bool = True # blend subject color stats toward background + + +def _extract_subject_bytes(image_bytes: bytes) -> bytes: + """Remove background from image using rembg; return RGBA PNG bytes.""" + if rembg_available(): + return remove_background_rembg(image_bytes) + raise RuntimeError( + "rembg is not installed. Run: pip install rembg (or add it to requirements.txt)" + ) + + +def _color_transfer_lab(subj_rgba: "Image", bg_rgb: "Image", blend: float = 0.45) -> "Image": + """ + Partial LAB color transfer: nudge subject color statistics 'blend' fraction + toward the background's statistics so it looks like it belongs in the scene. + """ + import cv2 + import numpy as np + from PIL import Image + + src_arr = np.array(subj_rgba.convert("RGB"), dtype=np.float32) + tgt_arr = np.array(bg_rgb.convert("RGB"), dtype=np.float32) + + alpha = np.array(subj_rgba.split()[3]) + subject_mask = alpha > 10 + + if not subject_mask.any(): + return subj_rgba + + src_lab = cv2.cvtColor(src_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32) + tgt_lab = cv2.cvtColor(tgt_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32) + + for ch in range(3): + src_ch = src_lab[:, :, ch] + src_pixels = src_ch[subject_mask] + tgt_pixels = tgt_lab[:, :, ch].flatten() + + src_mean, src_std = float(src_pixels.mean()), float(src_pixels.std()) + 1e-6 + tgt_mean, tgt_std = float(tgt_pixels.mean()), float(tgt_pixels.std()) + 1e-6 + + adjusted_std = src_std + blend * (tgt_std - src_std) + adjusted = (src_ch - src_mean) * (adjusted_std / src_std) + src_mean + blend * (tgt_mean - src_mean) + src_lab[:, :, ch] = np.clip(adjusted, 0, 255) + + result_rgb = cv2.cvtColor(src_lab.astype(np.uint8), cv2.COLOR_LAB2RGB) + r, g, b = result_rgb[:, :, 0], result_rgb[:, :, 1], result_rgb[:, :, 2] + return Image.merge("RGBA", [ + Image.fromarray(r), Image.fromarray(g), + Image.fromarray(b), Image.fromarray(alpha), + ]) + + +def _do_replace_subject( + bg_bytes: bytes, + subj_bytes: bytes, + mask_bytes: Optional[bytes], + match_colors: bool, +) -> bytes: + """Core compositing: extract subject → scale → color-match → paste onto background.""" + import numpy as np + from PIL import Image + + bg_img = Image.open(BytesIO(bg_bytes)).convert("RGBA") + + subj_rgba = Image.open(BytesIO(_extract_subject_bytes(subj_bytes))).convert("RGBA") + + # Determine target placement bounding box from mask or full canvas + if mask_bytes: + mask_img = Image.open(BytesIO(mask_bytes)).convert("L") + if mask_img.size != bg_img.size: + mask_img = mask_img.resize(bg_img.size, Image.LANCZOS) + mask_arr = np.array(mask_img) + ys, xs = np.where(mask_arr > 128) + else: + mask_img = None + mask_arr = None + ys, xs = np.array([]), np.array([]) + + if len(xs) > 0: + minx, maxx = int(xs.min()), int(xs.max()) + miny, maxy = int(ys.min()), int(ys.max()) + else: + minx, miny = 0, 0 + maxx, maxy = bg_img.width - 1, bg_img.height - 1 + + target_w = maxx - minx + 1 + target_h = maxy - miny + 1 + + # Scale subject to fit target area, preserving aspect ratio + sw, sh = subj_rgba.size + scale = min(target_w / sw, target_h / sh) + new_w = max(1, round(sw * scale)) + new_h = max(1, round(sh * scale)) + subj_scaled = subj_rgba.resize((new_w, new_h), Image.LANCZOS) + + # Optional color transfer to blend lighting/tone + if match_colors: + subj_scaled = _color_transfer_lab(subj_scaled, bg_img.convert("RGB")) + + # Center in target area + px = minx + (target_w - new_w) // 2 + py = miny + (target_h - new_h) // 2 + + result = bg_img.copy() + + if mask_img is not None and len(xs) > 0: + # Build a full-canvas RGBA layer for the subject + subj_canvas = Image.new("RGBA", bg_img.size, (0, 0, 0, 0)) + subj_canvas.paste(subj_scaled, (px, py), subj_scaled.split()[3]) + # Clip subject's alpha to the selection mask + sc_arr = np.array(subj_canvas) + sc_arr[:, :, 3] = np.minimum(sc_arr[:, :, 3], mask_arr).astype(np.uint8) + subj_canvas = Image.fromarray(sc_arr) + result.paste(subj_canvas, (0, 0), subj_canvas.split()[3]) + else: + result.paste(subj_scaled, (px, py), subj_scaled.split()[3]) + + out = BytesIO() + result.convert("RGB").save(out, format="PNG") + return out.getvalue() + + +@router.post("/image/extract-subject") +async def extract_subject(req: ExtractSubjectRequest): + """ + Remove background from an image and return the subject with transparency (RGBA PNG). + Uses rembg (AI-powered) when available. + """ + try: + result = await asyncio.get_event_loop().run_in_executor( + None, _extract_subject_bytes, _decode(req.image) + ) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/image/replace-subject") +async def replace_subject(req: ReplaceSubjectRequest): + """ + Extract the primary subject from `subject_image` (via rembg background removal), + scale it to fit the `mask` selection on `background_image`, apply optional LAB + color transfer for lighting consistency, and composite the result. + + Returns the composited image as base64 PNG. + """ + try: + result = await asyncio.get_event_loop().run_in_executor( + None, + _do_replace_subject, + _decode(req.background_image), + _decode(req.subject_image), + _decode(req.mask) if req.mask else None, + req.match_colors, + ) + return {"result": _encode(result)} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +# ─── Extract colors ─────────────────────────────────────────────────────────── + +class ExtractColorsRequest(BaseModel): + image: str # base64 + count: int = 6 + + +def _extract_colors(image_bytes: bytes, count: int) -> list[str]: + """ + Resize image to 150×150, k-means cluster pixels into `count` groups + using pure numpy (no sklearn dependency), return hex strings by frequency. + """ + import numpy as np + from PIL import Image + from io import BytesIO + + count = max(1, min(count, 32)) + + pil = Image.open(BytesIO(image_bytes)).convert("RGB").resize((150, 150)) + pixels = np.array(pil, dtype=np.float32).reshape(-1, 3) # (22500, 3) + n = len(pixels) + + # Initialise centers with k-means++ seeding + rng = np.random.default_rng(42) + centers = [pixels[rng.integers(n)]] + for _ in range(count - 1): + dists = np.min([np.sum((pixels - c) ** 2, axis=1) for c in centers], axis=0) + probs = dists / dists.sum() + centers.append(pixels[rng.choice(n, p=probs)]) + centers = np.array(centers) + + labels = np.zeros(n, dtype=np.int32) + for _ in range(20): # max 20 iterations + # Assign each pixel to nearest center + dists = np.sum((pixels[:, None] - centers[None]) ** 2, axis=2) # (n, k) + new_labels = np.argmin(dists, axis=1) + if np.all(new_labels == labels): + break + labels = new_labels + # Recompute centers + for k in range(count): + mask = labels == k + if mask.any(): + centers[k] = pixels[mask].mean(axis=0) + + counts = np.bincount(labels, minlength=count) + order = np.argsort(-counts) + + return [ + "#{:02x}{:02x}{:02x}".format(*centers[i].astype(int).clip(0, 255)) + for i in order + ] + + +@router.post("/extract-colors") +async def extract_colors(req: ExtractColorsRequest): + """ + Extract dominant colors from an image using k-means clustering. + Returns hex color strings sorted by frequency (most dominant first). + """ + try: + image_bytes = _decode(req.image) + colors = await asyncio.get_event_loop().run_in_executor( + None, _extract_colors, image_bytes, req.count + ) + return {"colors": colors} + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) diff --git a/paintplus/backend/app/routers/edits.py b/paintplus/backend/app/routers/edits.py new file mode 100644 index 0000000..d8f28a9 --- /dev/null +++ b/paintplus/backend/app/routers/edits.py @@ -0,0 +1,171 @@ +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +import json + +from app.database import get_db +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import EditRequest, EditResponse, StatusResponse +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/edits", tags=["edits"]) + + +async def process_edit_background( + edit_id: int, + project_id: int, + request: EditRequest, + db: Session +): + """Background task to process edit""" + edit_service = EditService() + + try: + # Process the edit + result_path = await edit_service.process_edit( + project_id=project_id, + edit_id=edit_id, + prompt=request.prompt, + mode=request.mode, + selection_type=request.selection_type, + bbox=request.bbox, + feather_px=request.feather_px, + selection_data=request.selection_data + ) + + # Update edit status + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if edit: + edit.status = "completed" + db.commit() + + except Exception as e: + # Update edit with error + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if edit: + edit.status = "failed" + edit.error_message = str(e) + db.commit() + + +@router.post("/projects/{project_id}/fix", response_model=EditResponse) +async def create_edit( + project_id: int, + request: EditRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Create a new edit request (Fix button) + + This endpoint accepts the selection data and prompt, + then processes the edit in the background. + """ + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Validate mode + if request.mode not in ["A", "B"]: + raise HTTPException(status_code=400, detail="Mode must be 'A' or 'B'") + + # Validate selection type + if request.selection_type not in ["rectangle", "ellipse", "lasso"]: + raise HTTPException(status_code=400, detail="Invalid selection type") + + # Create edit record + edit = Edit( + project_id=project_id, + mode=request.mode, + prompt=request.prompt, + selection_type=request.selection_type, + bbox_json=json.dumps(request.bbox), + feather_px=request.feather_px, + ai_provider=settings.ai_provider, + status="pending" + ) + db.add(edit) + db.commit() + db.refresh(edit) + + # Process edit in background + background_tasks.add_task( + process_edit_background, + edit.id, + project_id, + request, + db + ) + + return edit + + +@router.get("/{edit_id}", response_model=EditResponse) +def get_edit( + edit_id: int, + db: Session = Depends(get_db) +): + """Get edit details and status""" + edit = db.query(Edit).filter(Edit.id == edit_id).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + return edit + + +@router.post("/projects/{project_id}/revert/{edit_id}", response_model=StatusResponse) +def revert_to_edit( + project_id: int, + edit_id: int, + db: Session = Depends(get_db) +): + """Revert project to a specific edit""" + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Verify edit exists and belongs to project + edit = db.query(Edit).filter( + Edit.id == edit_id, + Edit.project_id == project_id + ).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + + # Revert + edit_service = EditService() + try: + result_path = edit_service.revert_to_edit(project_id, edit_id) + return StatusResponse( + status="success", + message=f"Reverted to edit {edit_id}", + data={"image_url": f"/projects/{project_id}/current"} + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.post("/projects/{project_id}/reset", response_model=StatusResponse) +def reset_to_original( + project_id: int, + db: Session = Depends(get_db) +): + """Reset project to original image""" + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Reset + edit_service = EditService() + try: + result_path = edit_service.reset_to_original(project_id) + return StatusResponse( + status="success", + message="Reset to original image", + data={"image_url": f"/projects/{project_id}/current"} + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) diff --git a/paintplus/backend/app/routers/generate.py b/paintplus/backend/app/routers/generate.py new file mode 100644 index 0000000..b247991 --- /dev/null +++ b/paintplus/backend/app/routers/generate.py @@ -0,0 +1,176 @@ +from fastapi import APIRouter, Depends, HTTPException, Form +from sqlalchemy.orm import Session +from typing import Optional +from PIL import Image +from io import BytesIO +import os + +from app.database import get_db +from app.models.project import Project +from app.schemas import TextToImageRequest, TextToImageResponse +from app.services.ai_provider import get_ai_provider +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/generate", tags=["generate"]) + + +@router.post("/text-to-image", response_model=TextToImageResponse) +async def text_to_image( + prompt: str = Form(...), + width: int = Form(1024), + height: int = Form(1024), + negative_prompt: Optional[str] = Form(None), + ai_provider: Optional[str] = Form(None), + ai_model: Optional[str] = Form(None), + create_project: bool = Form(True), + project_name: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """ + Generate an image from text prompt + + Args: + prompt: Text description of desired image + width: Image width (default 1024) + height: Image height (default 1024) + negative_prompt: What to avoid in generation + ai_provider: Override default AI provider + ai_model: Specific model to use + create_project: Whether to create a new project with the result + project_name: Name for the new project (if create_project=True) + + Returns: + Generated image info and optionally project details + """ + + # Validate dimensions + if width < 256 or width > 2048 or height < 256 or height > 2048: + raise HTTPException( + status_code=400, + detail="Width and height must be between 256 and 2048" + ) + + # Get AI provider + provider = get_ai_provider(ai_provider, ai_model) + + try: + # Generate image + image_bytes = await provider.text_to_image( + prompt=prompt, + width=width, + height=height, + model=ai_model, + negative_prompt=negative_prompt + ) + + project_id = None + image_url = None + + if create_project: + # Create a new project + project = Project( + name=project_name or f"Generated: {prompt[:50]}", + user_id=None # TODO: Add authentication + ) + db.add(project) + db.commit() + db.refresh(project) + + project_id = project.id + + # Save image as both original and current + edit_service = EditService() + edit_service.ensure_project_dir(project_id) + + original_path = edit_service.get_original_image_path(project_id) + current_path = edit_service.get_current_image_path(project_id) + + # Save image + img = Image.open(BytesIO(image_bytes)) + img.save(original_path, 'PNG') + img.save(current_path, 'PNG') + + image_url = f"/projects/{project_id}/current" + + return TextToImageResponse( + status="success", + prompt=prompt, + width=width, + height=height, + project_id=project_id, + image_url=image_url, + ai_provider=ai_provider or settings.ai_provider, + ai_model=ai_model + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/layer/text-to-image", response_model=TextToImageResponse) +async def text_to_image_layer( + project_id: int = Form(...), + prompt: str = Form(...), + width: int = Form(512), + height: int = Form(512), + x: int = Form(0), + y: int = Form(0), + negative_prompt: Optional[str] = Form(None), + ai_provider: Optional[str] = Form(None), + ai_model: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """ + Generate an image as a new layer in an existing project + + This generates a smaller image that can be placed as a layer + on top of the current project canvas. + """ + + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Get AI provider + provider = get_ai_provider(ai_provider, ai_model) + + try: + # Generate image + image_bytes = await provider.text_to_image( + prompt=prompt, + width=width, + height=height, + model=ai_model, + negative_prompt=negative_prompt + ) + + # Save as temporary layer file + edit_service = EditService() + layers_dir = edit_service.get_project_dir(project_id) / "layers" + layers_dir.mkdir(exist_ok=True) + + # Generate unique layer filename + import time + layer_filename = f"generated_{int(time.time())}.png" + layer_path = layers_dir / layer_filename + + # Save layer image + with open(layer_path, 'wb') as f: + f.write(image_bytes) + + return TextToImageResponse( + status="success", + prompt=prompt, + width=width, + height=height, + project_id=project_id, + image_url=f"/projects/{project_id}/layers/{layer_filename}", + layer_position={"x": x, "y": y}, + ai_provider=ai_provider or settings.ai_provider, + ai_model=ai_model + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/paintplus/backend/app/routers/gpu_status.py b/paintplus/backend/app/routers/gpu_status.py new file mode 100644 index 0000000..68316b9 --- /dev/null +++ b/paintplus/backend/app/routers/gpu_status.py @@ -0,0 +1,96 @@ +""" +GPU status and model management endpoints. +All under /api/gpu prefix. +""" +from fastapi import APIRouter +from pydantic import BaseModel +from typing import Optional, List +import asyncio + +router = APIRouter(prefix="/api/gpu", tags=["gpu"]) + + +@router.get("/status") +async def gpu_status(): + """ + Full GPU capability report: hardware, feature flags, VRAM budget, + and which model was selected for each operation. + Frontend polls this to show GPU badge and tool availability. + """ + from app.services.gpu_detect import get_cached_gpu_info + from app.services.local_diffusion import get_all_model_states + + info = get_cached_gpu_info() + + return { + # Hardware + "backend": info.backend, + "device_name": info.device_name, + "vram_total_gb": info.vram_total_gb, + "vram_free_gb": info.vram_free_gb, + "compute_capability": info.compute_capability, + # Feature flags + "fp16": info.fp16, + "bf16": info.bf16, + "fp8": info.fp8, + "int8": info.int8, + "tensor_cores": info.tensor_cores, + "xformers": info.xformers, + # Derived + "effective_vram_gb": info.effective_vram_gb, + "tier": info.tier, + # Selected models per operation + "recommended": { + op: ( + { + "model_id": spec.model_id, + "family": spec.family, + "memory_opt": spec.memory_opt, + "native_res": spec.native_res, + "vram_fp16_gb": spec.vram_fp16_gb, + } + if spec else None + ) + for op, spec in info.recommended.items() + }, + "pipeline_states": get_all_model_states(), + "warnings": info.warnings, + "capabilities": info.capabilities, + } + + +class PrefetchRequest(BaseModel): + operations: Optional[List[str]] = None + + +@router.post("/prefetch") +async def prefetch_models(req: PrefetchRequest = PrefetchRequest()): + """ + Eagerly load pipelines into GPU memory for the requested operations. + Returns immediately; poll /api/gpu/prefetch-status for progress. + Default: inpaint, txt2img, img2img. + """ + ops = req.operations or ["inpaint", "txt2img", "img2img"] + valid = {"inpaint", "txt2img", "img2img", "outpaint", "upscale"} + ops = [op for op in ops if op in valid] + + from app.services.local_diffusion import get_local_diffusion_provider + provider = get_local_diffusion_provider() + + async def _prefetch(): + for op in ops: + try: + await provider._get_pipeline(op) + print(f"[gpu] Prefetch complete: {op}") + except Exception as exc: + print(f"[gpu] Prefetch failed for {op}: {exc}") + + asyncio.create_task(_prefetch()) + return {"status": "prefetch_started", "operations": ops} + + +@router.get("/prefetch-status") +async def prefetch_status(): + """Poll model download / load progress.""" + from app.services.local_diffusion import get_all_model_states + return {"models": get_all_model_states()} diff --git a/paintplus/backend/app/routers/images.py b/paintplus/backend/app/routers/images.py new file mode 100644 index 0000000..514f798 --- /dev/null +++ b/paintplus/backend/app/routers/images.py @@ -0,0 +1,81 @@ +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from pathlib import Path + +from app.database import get_db +from app.models.project import Project +from app.services.edit_service import EditService + +router = APIRouter(prefix="/projects", tags=["images"]) + + +@router.get("/{project_id}/original") +def get_original_image( + project_id: int, + db: Session = Depends(get_db) +): + """Get the original uploaded image""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + image_path = edit_service.get_original_image_path(project_id) + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Original image not found") + + return FileResponse( + image_path, + media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"} + ) + + +@router.get("/{project_id}/current") +def get_current_image( + project_id: int, + db: Session = Depends(get_db) +): + """Get the current edited image""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + if not image_path.exists(): + raise HTTPException(status_code=404, detail="Current image not found") + + return FileResponse( + image_path, + media_type="image/png", + headers={"Cache-Control": "no-cache"} + ) + + +@router.get("/{project_id}/history/{edit_id}/result") +def get_edit_result( + project_id: int, + edit_id: int, + db: Session = Depends(get_db) +): + """Get the result image from a specific edit""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edit_service = EditService() + edit_dir = edit_service.get_edit_dir(project_id, edit_id) + result_path = edit_dir / "result.png" + + if not result_path.exists(): + raise HTTPException(status_code=404, detail="Edit result not found") + + return FileResponse( + result_path, + media_type="image/png", + headers={"Cache-Control": "public, max-age=3600"} + ) diff --git a/paintplus/backend/app/routers/patches.py b/paintplus/backend/app/routers/patches.py new file mode 100644 index 0000000..618e002 --- /dev/null +++ b/paintplus/backend/app/routers/patches.py @@ -0,0 +1,308 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session +from typing import List, Optional +import json + +from app.database import get_db +from app.models.patch import Patch +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import PatchCreate, PatchResponse, PatchApply, StatusResponse +from app.services.patch_library import PatchLibraryService +from app.config import settings + +router = APIRouter(prefix="/patches", tags=["patches"]) + + +@router.post("/", response_model=PatchResponse) +async def create_patch( + name: str = Form(...), + description: Optional[str] = Form(None), + source_type: str = Form(...), + category: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + source_project_id: Optional[int] = Form(None), + source_edit_id: Optional[int] = Form(None), + bbox: Optional[str] = Form(None), + file: Optional[UploadFile] = File(None), + db: Session = Depends(get_db) +): + """ + Create a new patch in the library + + Source types: + - ai_generated: From an edit (requires source_edit_id) + - manual_selection: Selected from current image (requires source_project_id and bbox) + - imported: Uploaded file (requires file) + """ + + # Validate source_type + if source_type not in ["ai_generated", "manual_selection", "imported"]: + raise HTTPException(status_code=400, detail="Invalid source_type") + + # Create patch record + patch = Patch( + name=name, + description=description, + source_type=source_type, + source_project_id=source_project_id, + source_edit_id=source_edit_id, + tags=tags, + category=category, + file_path="", # Will be set after saving + user_id=None # TODO: Add authentication + ) + + db.add(patch) + db.commit() + db.refresh(patch) + + # Save patch file based on source type + patch_service = PatchLibraryService() + + try: + if source_type == "ai_generated": + # Get edit directory and save AI-generated patch + if not source_edit_id: + raise HTTPException(status_code=400, detail="source_edit_id required for ai_generated") + + edit = db.query(Edit).filter(Edit.id == source_edit_id).first() + if not edit: + raise HTTPException(status_code=404, detail="Edit not found") + + from app.services.edit_service import EditService + edit_service = EditService() + edit_dir = edit_service.get_edit_dir(edit.project_id, edit.id) + + file_path = patch_service.save_ai_generated_patch(patch.id, edit_dir) + + # Get dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + + elif source_type == "manual_selection": + # Save manually selected patch from current image + if not source_project_id or not bbox: + raise HTTPException( + status_code=400, + detail="source_project_id and bbox required for manual_selection" + ) + + project = db.query(Project).filter(Project.id == source_project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox + file_path = patch_service.save_manual_patch(patch.id, source_project_id, bbox_dict) + + patch.width = bbox_dict['width'] + patch.height = bbox_dict['height'] + + elif source_type == "imported": + # Save uploaded file + if not file: + raise HTTPException(status_code=400, detail="file required for imported") + + image_bytes = await file.read() + file_path = patch_service.save_patch_from_bytes(patch.id, image_bytes) + + # Get dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + + # Update patch with file path + patch.file_path = file_path + patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id)) + db.commit() + db.refresh(patch) + + return patch + + except Exception as e: + # Cleanup on error + patch_service.delete_patch(patch.id) + db.delete(patch) + db.commit() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/", response_model=List[PatchResponse]) +def list_patches( + category: Optional[str] = None, + tags: Optional[str] = None, + limit: int = 50, + offset: int = 0, + db: Session = Depends(get_db) +): + """List patches in the library with optional filtering""" + + query = db.query(Patch) + + if category: + query = query.filter(Patch.category == category) + + if tags: + # Simple tag search (could be improved with full-text search) + query = query.filter(Patch.tags.like(f"%{tags}%")) + + patches = query.offset(offset).limit(limit).all() + return patches + + +@router.get("/{patch_id}", response_model=PatchResponse) +def get_patch( + patch_id: int, + db: Session = Depends(get_db) +): + """Get patch details""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + return patch + + +@router.get("/{patch_id}/image") +def get_patch_image( + patch_id: int, + thumbnail: bool = False, + db: Session = Depends(get_db) +): + """Get patch image file""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + patch_service = PatchLibraryService() + + if thumbnail: + file_path = patch_service.get_thumbnail_path(patch_id) + else: + file_path = patch_service.get_patch_path(patch_id) + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Patch image not found") + + return FileResponse(file_path, media_type="image/png") + + +@router.post("/apply", response_model=StatusResponse) +async def apply_patch( + project_id: int = Form(...), + patch_id: int = Form(...), + bbox: str = Form(...), + feather_px: int = Form(5), + db: Session = Depends(get_db) +): + """ + Apply a saved patch to a project image + + This creates a new edit in the project history. + """ + # Verify project exists + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Verify patch exists + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + # Parse bbox + bbox_dict = json.loads(bbox) if isinstance(bbox, str) else bbox + + # Load current image + from app.services.edit_service import EditService + from PIL import Image + + edit_service = EditService() + current_image_path = edit_service.get_current_image_path(project_id) + current_image = Image.open(current_image_path).convert('RGBA') + + # Apply patch + patch_service = PatchLibraryService() + result_image = patch_service.apply_patch_to_image( + patch_id, + current_image, + bbox_dict, + feather_px + ) + + # Save result as current image + result_image.save(current_image_path) + + # Create edit record + edit = Edit( + project_id=project_id, + mode="patch_library", + prompt=f"Applied saved patch: {patch.name}", + selection_type="rectangle", + bbox_json=json.dumps(bbox_dict), + feather_px=feather_px, + ai_provider="patch_library", + status="completed" + ) + db.add(edit) + db.commit() + + return StatusResponse( + status="success", + message=f"Applied patch '{patch.name}' to project", + data={"edit_id": edit.id} + ) + + +@router.delete("/{patch_id}", response_model=StatusResponse) +def delete_patch( + patch_id: int, + db: Session = Depends(get_db) +): + """Delete a patch from the library""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + # Delete files + patch_service = PatchLibraryService() + patch_service.delete_patch(patch_id) + + # Delete record + db.delete(patch) + db.commit() + + return StatusResponse( + status="success", + message=f"Deleted patch '{patch.name}'" + ) + + +@router.put("/{patch_id}", response_model=PatchResponse) +def update_patch( + patch_id: int, + name: Optional[str] = None, + description: Optional[str] = None, + category: Optional[str] = None, + tags: Optional[str] = None, + db: Session = Depends(get_db) +): + """Update patch metadata""" + patch = db.query(Patch).filter(Patch.id == patch_id).first() + if not patch: + raise HTTPException(status_code=404, detail="Patch not found") + + if name: + patch.name = name + if description is not None: + patch.description = description + if category: + patch.category = category + if tags is not None: + patch.tags = tags + + db.commit() + db.refresh(patch) + + return patch diff --git a/paintplus/backend/app/routers/print_tools.py b/paintplus/backend/app/routers/print_tools.py new file mode 100644 index 0000000..71f8046 --- /dev/null +++ b/paintplus/backend/app/routers/print_tools.py @@ -0,0 +1,487 @@ +""" +Print / frame tools — frame fit and upscale. +All endpoints under /api/print prefix. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Literal +import base64 +import asyncio +from io import BytesIO +from PIL import Image +import numpy as np + +router = APIRouter(prefix="/api/print", tags=["print-tools"]) + +# ── Frame size catalogue (inches) ────────────────────────────────────────── +FRAME_SIZES = { + "4x6": (4, 6), + "5x7": (5, 7), + "8x10": (8, 10), + "11x14": (11, 14), + "16x20": (16, 20), + "18x24": (18, 24), + "20x24": (20, 24), + "24x36": (24, 36), + # Square + "4x4": (4, 4), + "8x8": (8, 8), + "12x12": (12, 12), +} + + +def _encode(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def _decode(b64: str) -> bytes: + return base64.b64decode(b64) + + +def _to_png(img: Image.Image) -> bytes: + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +# ── Request models ───────────────────────────────────────────────────────── + +class FrameFitRequest(BaseModel): + image: str # base64 PNG/JPEG + frame: str # e.g. "8x10" + orientation: Literal["auto", "portrait", "landscape"] = "auto" + mode: Literal["crop", "extend", "smart"] = "smart" + dpi: int = 300 + # For extend mode: prompt passed to outpaint + prompt: Optional[str] = "" + # Smart mode threshold: extend if gap fraction < this, else crop + smart_threshold: float = 0.15 + + +class UpscaleRequest(BaseModel): + image: str # base64 + scale: float = 2.0 # 1.5, 2, 3, 4 + # auto = pick best available; lanczos = always works; realesrgan_pytorch / realesrgan_ncnn = explicit + method: str = "auto" + + +class PrepareRequest(BaseModel): + image: str # base64 + frame: str # e.g. "8x10" + orientation: Literal["auto", "portrait", "landscape"] = "auto" + target_dpi: int = 300 + upscale_method: str = "auto" # auto / realesrgan_pytorch / realesrgan_ncnn / lanczos + mode: Literal["crop", "extend", "smart"] = "smart" + prompt: Optional[str] = "" + + +# ── Frame sizes endpoint ─────────────────────────────────────────────────── + +@router.get("/frame-sizes") +def list_frame_sizes(): + """Return the catalogue of supported frame sizes.""" + return { + "sizes": list(FRAME_SIZES.keys()), + "catalogue": {k: {"inches": v, "pixels_300dpi": (v[0]*300, v[1]*300)} + for k, v in FRAME_SIZES.items()}, + } + + +# ── Frame fit ────────────────────────────────────────────────────────────── + +@router.post("/frame-fit") +async def frame_fit(req: FrameFitRequest): + """ + Fit an image to a print frame size. + + Modes: + crop — center-crop to frame aspect ratio, then scale to print resolution. + extend — scale to fill one dimension, outpaint the gap with AI. + smart — extend if gap < smart_threshold of frame dimension, else crop. + + Returns the fitted image plus a summary of what was done. + """ + if req.frame not in FRAME_SIZES: + raise HTTPException(status_code=400, + detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}") + if not (72 <= req.dpi <= 600): + raise HTTPException(status_code=400, detail="dpi must be 72–600") + + try: + image = Image.open(BytesIO(_decode(req.image))).convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + fw, fh = FRAME_SIZES[req.frame] # frame inches (w, h in portrait) + + # Resolve orientation + img_w, img_h = image.size + img_landscape = img_w >= img_h + frame_landscape = fw >= fh + + if req.orientation == "landscape": + fw, fh = max(fw, fh), min(fw, fh) + elif req.orientation == "portrait": + fw, fh = min(fw, fh), max(fw, fh) + else: # auto — match image orientation + if img_landscape and not frame_landscape: + fw, fh = fh, fw # rotate frame to landscape + elif not img_landscape and frame_landscape: + fw, fh = fh, fw # rotate frame to portrait + + target_w = fw * req.dpi + target_h = fh * req.dpi + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + # Determine actual mode + mode = req.mode + if mode == "smart": + # Scale image to fill the frame — compute gap fraction + if img_ratio > target_ratio: + # Image wider → fits on height, gap on width + scaled_h = target_h + scaled_w = round(target_h * img_ratio) + gap_frac = (scaled_w - target_w) / target_w # positive = overflow (crop) + else: + scaled_w = target_w + scaled_h = round(target_w / img_ratio) + gap_frac = (scaled_h - target_h) / target_h + + # gap_frac > 0 means we'd need to crop; < 0 means we'd need to extend + if gap_frac < 0: + # Need to extend — use extend if gap is small enough + mode = "extend" if abs(gap_frac) <= req.smart_threshold else "crop" + else: + mode = "crop" + + if mode == "crop": + result, summary = _crop_fit(image, target_w, target_h) + else: # extend + result, summary = await _extend_fit(image, target_w, target_h, req.prompt or "") + + return { + "result": _encode(_to_png(result)), + "mode_used": mode, + "frame": req.frame, + "orientation": "landscape" if fw > fh else "portrait", + "output_pixels": {"width": result.width, "height": result.height}, + "output_inches": {"width": fw, "height": fh}, + "dpi": req.dpi, + "summary": summary, + } + + +def _crop_fit(image: Image.Image, target_w: int, target_h: int): + """Center-crop image to target aspect ratio, then Lanczos scale to target size.""" + img_w, img_h = image.size + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + if img_ratio > target_ratio: + # Wider than target — crop sides + new_w = round(img_h * target_ratio) + x0 = (img_w - new_w) // 2 + cropped = image.crop((x0, 0, x0 + new_w, img_h)) + else: + # Taller than target — crop top/bottom + new_h = round(img_w / target_ratio) + y0 = (img_h - new_h) // 2 + cropped = image.crop((0, y0, img_w, y0 + new_h)) + + result = cropped.resize((target_w, target_h), Image.Resampling.LANCZOS) + summary = ( + f"Cropped from {img_w}×{img_h} to {cropped.width}×{cropped.height}, " + f"scaled to {target_w}×{target_h}" + ) + return result, summary + + +async def _extend_fit(image: Image.Image, target_w: int, target_h: int, prompt: str): + """ + Scale image to fill one dimension exactly, then outpaint the gap with AI. + Falls back to content-aware mirror fill if no remote provider configured. + """ + from app.services.remote_provider import get_remote_provider + + img_w, img_h = image.size + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + if img_ratio > target_ratio: + # Image wider — scale to target width, extend height + scale = target_w / img_w + scaled_w = target_w + scaled_h = round(img_h * scale) + gap_dir = "height" + gap_top = (target_h - scaled_h) // 2 + gap_bottom = target_h - scaled_h - gap_top + else: + # Image taller — scale to target height, extend width + scale = target_h / img_h + scaled_h = target_h + scaled_w = round(img_w * scale) + gap_dir = "width" + gap_left = (target_w - scaled_w) // 2 + gap_right = target_w - scaled_w - gap_left + + scaled = image.resize((scaled_w, scaled_h), Image.Resampling.LANCZOS) + + # Place scaled image on canvas + canvas = Image.new("RGB", (target_w, target_h), (128, 128, 128)) + if gap_dir == "height": + canvas.paste(scaled, (0, gap_top)) + # Build mask: top and bottom strips are white (to inpaint) + mask = Image.new("L", (target_w, target_h), 0) + if gap_top > 0: + mask.paste(Image.new("L", (target_w, gap_top), 255), (0, 0)) + if gap_bottom > 0: + mask.paste(Image.new("L", (target_w, gap_bottom), 255), (0, target_h - gap_bottom)) + else: + canvas.paste(scaled, (gap_left, 0)) + mask = Image.new("L", (target_w, target_h), 0) + if gap_left > 0: + mask.paste(Image.new("L", (gap_left, target_h), 255), (0, 0)) + if gap_right > 0: + mask.paste(Image.new("L", (gap_right, target_h), 255), (target_w - gap_right, 0)) + + # Try AI inpaint + provider = get_remote_provider("inpaint") + if provider: + try: + canvas_bytes = _to_png(canvas) + mask_bytes = _to_png(mask) + fill_prompt = prompt or "seamlessly continue the image, natural extension" + result_bytes = await provider.inpaint(canvas_bytes, mask_bytes, fill_prompt, {}) + result = Image.open(BytesIO(result_bytes)).convert("RGB") + summary = ( + f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, " + f"AI-extended {gap_dir} to {target_w}×{target_h}" + ) + return result, summary + except Exception as e: + print(f"AI extend failed, using mirror fill: {e}") + + # Fallback: mirror-fill the gap (looks decent for backgrounds/landscapes) + result = _mirror_fill(canvas, mask, scaled, gap_dir, + gap_top if gap_dir == "height" else gap_left, + gap_bottom if gap_dir == "height" else gap_right, + target_w, target_h) + summary = ( + f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, " + f"mirror-filled {gap_dir} to {target_w}×{target_h} (no AI provider)" + ) + return result, summary + + +def _mirror_fill(canvas, mask, scaled, gap_dir, gap_a, gap_b, target_w, target_h): + """Fill gaps by reflecting the nearest edge strip.""" + result = canvas.copy() + if gap_dir == "height": + if gap_a > 0: + strip = scaled.crop((0, 0, scaled.width, min(gap_a * 2, scaled.height))) + strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + strip = strip.resize((target_w, gap_a), Image.Resampling.LANCZOS) + result.paste(strip, (0, 0)) + if gap_b > 0: + strip = scaled.crop((0, max(0, scaled.height - gap_b * 2), scaled.width, scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + strip = strip.resize((target_w, gap_b), Image.Resampling.LANCZOS) + result.paste(strip, (0, target_h - gap_b)) + else: + if gap_a > 0: + strip = scaled.crop((0, 0, min(gap_a * 2, scaled.width), scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + strip = strip.resize((gap_a, target_h), Image.Resampling.LANCZOS) + result.paste(strip, (0, 0)) + if gap_b > 0: + strip = scaled.crop((max(0, scaled.width - gap_b * 2), 0, scaled.width, scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + strip = strip.resize((gap_b, target_h), Image.Resampling.LANCZOS) + result.paste(strip, (target_w - gap_b, 0)) + return result + + +# ── Upscale ──────────────────────────────────────────────────────────────── + +@router.post("/upscale/refresh-caps") +def upscale_refresh_caps(): + """Bust the capability cache (call after installing Real-ESRGAN without restarting).""" + from app.services.upscale import invalidate_caps_cache, probe_upscale_capabilities + invalidate_caps_cache() + return probe_upscale_capabilities() + + +@router.get("/upscale/available") +async def upscale_available(): + """ + Return capability probe: which upscale methods are available, + which device will be used, and which method is recommended. + If no AI upscaler is found, triggers background NCNN auto-install. + Frontend uses this to populate the method selector. + """ + from app.services.upscale import probe_upscale_capabilities, ensure_ncnn_installed, get_install_status + caps = probe_upscale_capabilities() + # Auto-install NCNN if no AI upscaler is available yet + if not caps["realesrgan_pytorch"] and not caps["realesrgan_ncnn"]: + asyncio.create_task(ensure_ncnn_installed()) + caps["ncnn_install_status"] = get_install_status() + return caps + + +@router.get("/upscale/install-status") +def upscale_install_status(): + """Poll for Real-ESRGAN NCNN auto-install progress.""" + from app.services.upscale import get_install_status, probe_upscale_capabilities, _find_ncnn_binary + status = get_install_status() + # If install just finished, refresh caps + if status["state"] == "done": + from app.services.upscale import invalidate_caps_cache + invalidate_caps_cache() + caps = probe_upscale_capabilities() + status["ncnn_available"] = caps["realesrgan_ncnn"] + else: + status["ncnn_available"] = False + return status + + +@router.post("/prepare") +async def prepare_for_print(req: PrepareRequest): + """ + One-shot Prepare for Print: AI upscale to reach target DPI, then fit to frame. + + Steps: + 1. Resolve target pixel dimensions (frame × target_dpi, orientation-adjusted) + 2. Calculate needed upscale factor so the image meets the target resolution + 3. Run Real-ESRGAN if scale > 1.05 (else skip — already large enough) + 4. Run frame-fit (crop / extend / smart) to exact target dimensions + 5. Return the print-ready image and a quality report + """ + if req.frame not in FRAME_SIZES: + raise HTTPException(status_code=400, + detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}") + if not (72 <= req.target_dpi <= 600): + raise HTTPException(status_code=400, detail="target_dpi must be 72–600") + + try: + image = Image.open(BytesIO(_decode(req.image))).convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + fw, fh = FRAME_SIZES[req.frame] + img_w, img_h = image.size + + # Resolve orientation (same logic as frame_fit) + img_landscape = img_w >= img_h + frame_landscape = fw >= fh + if req.orientation == "landscape": + fw, fh = max(fw, fh), min(fw, fh) + elif req.orientation == "portrait": + fw, fh = min(fw, fh), max(fw, fh) + else: + if img_landscape and not frame_landscape: + fw, fh = fh, fw + elif not img_landscape and frame_landscape: + fw, fh = fh, fw + + target_w = fw * req.target_dpi + target_h = fh * req.target_dpi + + # Scale factor needed so the shorter dimension fills the frame + scale_w = target_w / img_w + scale_h = target_h / img_h + needed_scale = min(scale_w, scale_h) # fill-to-fit (extend) baseline + # For crop mode we need max; use the larger to be safe and let frame-fit crop + needed_scale_crop = max(scale_w, scale_h) + + # Use the smaller (extend) scale as the upscale target; frame-fit handles the rest + upscale_factor = max(1.0, needed_scale) + upscale_applied = False + method_used = "none" + + upscaled = image + if upscale_factor > 1.05: + # Cap per-pass at 4× (Real-ESRGAN works best at 2–4×) + remaining = upscale_factor + while remaining > 1.05: + pass_scale = min(remaining, 4.0) + # Round to one decimal to keep scale in 1.1–8.0 range accepted by upscale service + pass_scale = round(pass_scale, 1) + if pass_scale < 1.1: + break + from app.services.upscale import upscale_image + result_bytes, method_used = await upscale_image(upscaled, pass_scale, req.upscale_method) + upscaled = Image.open(BytesIO(result_bytes)).convert("RGB") + remaining /= pass_scale + upscale_applied = True + + # Encode upscaled image and run frame-fit + upscaled_b64 = _encode(_to_png(upscaled)) + + fit_req = FrameFitRequest( + image=upscaled_b64, + frame=req.frame, + orientation=req.orientation, + mode=req.mode, + dpi=req.target_dpi, + prompt=req.prompt or "", + ) + # Re-use the existing frame_fit logic inline + fit_response = await frame_fit(fit_req) + + return { + "result": fit_response["result"], + "frame": req.frame, + "orientation": fit_response["orientation"], + "output_pixels": fit_response["output_pixels"], + "output_inches": fit_response["output_inches"], + "dpi": req.target_dpi, + "mode_used": fit_response["mode_used"], + "upscale_applied": upscale_applied, + "upscale_factor": round(upscale_factor, 2), + "upscale_method": method_used, + "summary": fit_response["summary"], + } + + +@router.post("/upscale") +async def upscale(req: UpscaleRequest): + """ + Upscale image. method values: + auto — pick best available (recommended) + realesrgan_pytorch — Real-ESRGAN via PyTorch (CUDA/MPS/CPU) + realesrgan_ncnn — Real-ESRGAN NCNN Vulkan binary + lanczos — always available, instant + Any AI method falls back to the next best if unavailable. + """ + if not (1.1 <= req.scale <= 8.0): + raise HTTPException(status_code=400, detail="scale must be 1.1–8.0") + + valid_methods = {"auto", "realesrgan_pytorch", "realesrgan_ncnn", "lanczos"} + if req.method not in valid_methods: + raise HTTPException(status_code=400, + detail=f"method must be one of {sorted(valid_methods)}") + + try: + image = Image.open(BytesIO(_decode(req.image))).convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + orig_w, orig_h = image.size + + try: + from app.services.upscale import upscale_image + result_bytes, method_used = await upscale_image(image, req.scale, req.method) + result = Image.open(BytesIO(result_bytes)) + except Exception as e: + import traceback; traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + return { + "result": _encode(result_bytes), + "method": method_used, + "original": {"width": orig_w, "height": orig_h}, + "output": {"width": result.width, "height": result.height}, + "scale": req.scale, + } diff --git a/paintplus/backend/app/routers/projects.py b/paintplus/backend/app/routers/projects.py new file mode 100644 index 0000000..3630007 --- /dev/null +++ b/paintplus/backend/app/routers/projects.py @@ -0,0 +1,144 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from sqlalchemy.orm import Session +from typing import List +import shutil +from pathlib import Path +from PIL import Image + +from app.database import get_db +from app.models.project import Project +from app.models.edit import Edit +from app.schemas import ProjectCreate, ProjectResponse, EditResponse, UploadResponse +from app.services.edit_service import EditService +from app.config import settings + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.post("/", response_model=ProjectResponse) +def create_project( + project: ProjectCreate, + db: Session = Depends(get_db) +): + """Create a new project""" + # For MVP, we'll use a default user_id of 1 + # In production, this would come from authentication + user_id = 1 + + db_project = Project( + user_id=user_id, + name=project.name + ) + db.add(db_project) + db.commit() + db.refresh(db_project) + + # Create project directory + edit_service = EditService() + edit_service.ensure_project_dir(db_project.id) + + return db_project + + +@router.get("/", response_model=List[ProjectResponse]) +def list_projects( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """List all projects""" + projects = db.query(Project).offset(skip).limit(limit).all() + return projects + + +@router.get("/{project_id}", response_model=ProjectResponse) +def get_project( + project_id: int, + db: Session = Depends(get_db) +): + """Get a specific project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +@router.delete("/{project_id}") +def delete_project( + project_id: int, + db: Session = Depends(get_db) +): + """Delete a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Delete project directory + edit_service = EditService() + project_dir = edit_service.get_project_dir(project_id) + if project_dir.exists(): + shutil.rmtree(project_dir) + + db.delete(project) + db.commit() + + return {"status": "success", "message": f"Project {project_id} deleted"} + + +@router.post("/{project_id}/upload", response_model=UploadResponse) +async def upload_image( + project_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """Upload an image to a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Validate file type + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Create project directory + edit_service = EditService() + edit_service.ensure_project_dir(project_id) + + # Save original and current images + original_path = edit_service.get_original_image_path(project_id) + current_path = edit_service.get_current_image_path(project_id) + + # Read and validate image + contents = await file.read() + try: + image = Image.open(BytesIO(contents)) + image = image.convert('RGBA') + + # Save images + image.save(original_path, 'PNG') + image.save(current_path, 'PNG') + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid image file: {str(e)}") + + return UploadResponse( + project_id=project_id, + original_url=f"/projects/{project_id}/original", + current_url=f"/projects/{project_id}/current" + ) + + +@router.get("/{project_id}/edits", response_model=List[EditResponse]) +def list_edits( + project_id: int, + db: Session = Depends(get_db) +): + """List all edits for a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + edits = db.query(Edit).filter(Edit.project_id == project_id).order_by(Edit.created_at.desc()).all() + return edits + + +from io import BytesIO diff --git a/paintplus/backend/app/routers/tools.py b/paintplus/backend/app/routers/tools.py new file mode 100644 index 0000000..244b1d0 --- /dev/null +++ b/paintplus/backend/app/routers/tools.py @@ -0,0 +1,1040 @@ +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form +from fastapi.responses import Response +from sqlalchemy.orm import Session +from typing import Optional +from PIL import Image +from io import BytesIO +import numpy as np +import json +import base64 +import cv2 +from pydantic import BaseModel + +from app.database import get_db +from app.models.project import Project +from app.schemas import StatusResponse + +router = APIRouter(prefix="/tools", tags=["tools"]) + + +# Pydantic models for JSON API +class SmartSelectRequest(BaseModel): + image: str # Base64 encoded image + point_x: int + point_y: int + + +class InpaintRequest(BaseModel): + image: str # Base64 encoded image + mask: str # Base64 encoded mask + prompt: str + negative_prompt: Optional[str] = "" + strength: Optional[float] = 0.8 + guidance_scale: Optional[float] = 7.5 + + +class RemoveBackgroundRequest(BaseModel): + image: str # Base64 encoded image + model: Optional[str] = "auto" # "auto", "ben2", "birefnet-hr", "u2net", "rembg" + + +@router.post("/smart-select-base64") +async def smart_select_base64(request: SmartSelectRequest): + """ + Smart select using base64 encoded image (no project required). + Used by miniPaint frontend. + """ + try: + # Decode base64 image + image_bytes = base64.b64decode(request.image) + img = Image.open(BytesIO(image_bytes)).convert('RGB') + img_array = np.array(img) + + # Run SAM selection + try: + mask = await _sam_select(img_array, request.point_x, request.point_y) + except Exception as e: + print(f"SAM not available, using flood fill: {e}") + mask = _flood_fill_select(img_array, request.point_x, request.point_y) + + # Convert mask to base64 PNG + mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L') + buffer = BytesIO() + mask_img.save(buffer, format='PNG') + mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + # Get polygon and bbox + polygon, bbox = _mask_to_polygon(mask) + + return { + "mask": mask_b64, + "polygon": polygon, + "bbox": bbox + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/inpaint") +async def inpaint_base64(request: InpaintRequest): + """ + AI inpainting using base64 encoded image and mask. + Used by miniPaint frontend. + """ + try: + # Decode base64 image and mask + image_bytes = base64.b64decode(request.image) + mask_bytes = base64.b64decode(request.mask) + + img = Image.open(BytesIO(image_bytes)).convert('RGB') + mask_img = Image.open(BytesIO(mask_bytes)).convert('L') + + # Resize mask to match image if needed + if mask_img.size != img.size: + mask_img = mask_img.resize(img.size, Image.Resampling.LANCZOS) + + # Get the AI provider + from app.services.ai_provider import get_ai_provider + + provider = get_ai_provider() + + # Convert images to bytes for provider + img_buffer = BytesIO() + img.save(img_buffer, format='PNG') + img_bytes = img_buffer.getvalue() + + mask_buffer = BytesIO() + mask_img.save(mask_buffer, format='PNG') + mask_bytes_png = mask_buffer.getvalue() + + # Run inpainting using edit_image method + result_bytes = await provider.edit_image( + patch_image_bytes=img_bytes, + mask_image_bytes=mask_bytes_png, + prompt=request.prompt, + mode="A" # Patch-only mode + ) + + # Convert result to base64 + result_b64 = base64.b64encode(result_bytes).decode('utf-8') + + return { + "result": result_b64 + } + + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/remove-background-base64") +async def remove_background_base64(request: RemoveBackgroundRequest): + """ + Remove background from a base64 encoded image. + + request.model selects the backend: + - "auto" (default): BG_REMOVAL_MODEL setting first, then falls back + through the other local models, then rembg as a last resort. + - "ben2" / "birefnet-hr" / "u2net": use only that local model. + - "rembg": skip local models, use rembg directly. + + Returns base64 encoded PNG with transparent background. + Used by miniPaint frontend. + """ + try: + from app.config import settings + + # Decode base64 image + image_bytes = base64.b64decode(request.image) + img = Image.open(BytesIO(image_bytes)).convert('RGB') + + local_backends = { + "ben2": _remove_background_ben2, + "birefnet-hr": _remove_background_birefnet_hr, + "u2net": _remove_background_u2net, + } + + if request.model in local_backends: + order = [request.model] + elif request.model == "rembg": + order = [] + else: + preferred = settings.bg_removal_model if settings.bg_removal_model in local_backends else "ben2" + order = [preferred] + [name for name in ("ben2", "u2net") if name != preferred] + + result_bytes = None + method_used = None + + for name in order: + try: + result_bytes = await local_backends[name](img) + method_used = name + break + except Exception as e: + print(f"{name} failed: {e}") + + # rembg is the universal last resort (also reachable directly via model="rembg") + if result_bytes is None and request.model in ("auto", "rembg"): + try: + from rembg import remove, new_session + try: + session = new_session("birefnet-general") + result_bytes = remove(image_bytes, session=session) + method_used = "birefnet" + except Exception: + result_bytes = remove(image_bytes) + method_used = "rembg-default" + except ImportError: + pass + except Exception as e: + print(f"rembg failed: {e}") + + if result_bytes is None: + raise HTTPException( + status_code=500, + detail="No background removal method available. Install ben2, u2net, or rembg." + ) + + # Convert result to base64 + result_b64 = base64.b64encode(result_bytes).decode('utf-8') + + # Get dimensions + result_img = Image.open(BytesIO(result_bytes)) + + return { + "result": result_b64, + "width": result_img.width, + "height": result_img.height, + "method": method_used + } + + except HTTPException: + raise + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + + +# Global U2Net model cache +_u2net_model = None + + +async def _download_u2net_model(models_dir): + """Auto-download U2Net PyTorch model (~176MB) for background removal""" + import urllib.request + from pathlib import Path + + models_dir = Path(models_dir) + models_dir.mkdir(parents=True, exist_ok=True) + + # Download U2Net PyTorch model (avoids ONNX executable stack issues in Docker) + # Using the PyTorch state dict format + url = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx" + dest_path = models_dir / "u2net.onnx" + + print(f"Downloading U2Net model from {url} (~176MB)...") + print("This may take a few minutes...") + + def download_progress(count, block_size, total_size): + if total_size > 0: + percent = min(100, count * block_size * 100 // total_size) + downloaded_mb = (count * block_size) / (1024 * 1024) + total_mb = total_size / (1024 * 1024) + if count % 500 == 0: + print(f" Download progress: {percent}% ({downloaded_mb:.1f}/{total_mb:.1f} MB)") + + urllib.request.urlretrieve(url, str(dest_path), download_progress) + print(f"U2Net model downloaded to {dest_path}") + + return dest_path + + +async def _remove_background_u2net(img: Image.Image) -> bytes: + """ + Remove background using U2Net model via OpenCV DNN. + Uses OpenCV's DNN module which doesn't have executable stack issues. + """ + global _u2net_model + + from pathlib import Path + + # Check for U2Net model + models_dir = Path('/app/data/models') + u2net_path = None + + # Check for ONNX model (preferred for OpenCV DNN) + for alt_name in ['u2net.onnx', 'u2netp.onnx']: + alt_path = models_dir / alt_name + if alt_path.exists(): + u2net_path = alt_path + break + + if u2net_path is None: + # Try to auto-download the model + print("U2Net model not found, attempting to download...") + try: + await _download_u2net_model(models_dir) + # Check again + for alt_name in ['u2net.onnx', 'u2netp.onnx']: + alt_path = models_dir / alt_name + if alt_path.exists(): + u2net_path = alt_path + break + except Exception as download_error: + print(f"Auto-download failed: {download_error}") + + if u2net_path is None: + raise FileNotFoundError( + "U2Net model not found. To fix this, run:\n" + " docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py\n" + "Or manually download from: https://github.com/danielgatis/rembg/releases" + ) + + # Load model if not cached (using OpenCV DNN - no executable stack issues) + if _u2net_model is None: + print(f"Loading U2Net model from {u2net_path} using OpenCV DNN") + try: + _u2net_model = cv2.dnn.readNetFromONNX(str(u2net_path)) + print("U2Net model loaded successfully with OpenCV DNN") + except Exception as e: + print(f"Failed to load with OpenCV DNN: {e}") + raise + + # Preprocess image + original_size = img.size + input_size = 320 + + # Resize and convert to blob + img_resized = img.resize((input_size, input_size), Image.Resampling.BILINEAR) + img_np = np.array(img_resized).astype(np.float32) + + # Normalize (ImageNet normalization) + img_np = img_np / 255.0 + img_np = (img_np - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] + + # Create blob (NCHW format) + blob = cv2.dnn.blobFromImage( + img_np.astype(np.float32), + scalefactor=1.0, + size=(input_size, input_size), + swapRB=False + ) + + # Run inference + _u2net_model.setInput(blob) + outputs = _u2net_model.forward() + + # Get mask from first output + mask = outputs[0, 0] + + # Post-process mask + mask = (mask - mask.min()) / (mask.max() - mask.min() + 1e-8) + mask = (mask * 255).astype(np.uint8) + + # Resize mask back to original size + mask_img = Image.fromarray(mask).resize(original_size, Image.Resampling.BILINEAR) + + # Apply mask to original image + result = img.convert('RGBA') + result.putalpha(mask_img) + + # Save to bytes + buffer = BytesIO() + result.save(buffer, format='PNG') + return buffer.getvalue() + + +# Global BEN2 model cache +_ben2_model = None + + +async def _remove_background_ben2(img: Image.Image) -> bytes: + """ + Remove background using BEN2 (Confidence Guided Matting) — clean cutouts, + strong on hair/fur edges. MIT licensed. Downloads weights from HF Hub on + first use (cached under the hf_cache bind mount). + """ + global _ben2_model + + if _ben2_model is None: + import torch + from ben2 import AutoModel as Ben2AutoModel + + device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f"Loading BEN2_Base model on {device} (first run downloads ~170MB from HuggingFace)") + _ben2_model = Ben2AutoModel.from_pretrained("PramaLLC/BEN2") + _ben2_model.to(device).eval() + print("BEN2_Base model loaded") + + # refine_foreground=True runs BEN2's extra foreground-color refinement pass + # (slower, but recovers fine/semi-transparent edge detail instead of a hard + # cutout — matters for things like lace, light rays, or fine text borders). + result = _ben2_model.inference(img.convert('RGB'), refine_foreground=True) + + buffer = BytesIO() + result.save(buffer, format='PNG') + return buffer.getvalue() + + +# Global BiRefNet-HR model cache +_birefnet_hr_model = None +_birefnet_hr_device = None + + +async def _remove_background_birefnet_hr(img: Image.Image) -> bytes: + """ + Remove background using BiRefNet-HR (2048x2048, MIT licensed) — best for + high-resolution / print work. Downloads weights from HF Hub on first use. + """ + global _birefnet_hr_model, _birefnet_hr_device + + import torch + from torchvision import transforms + + if _birefnet_hr_model is None: + from transformers import AutoModelForImageSegmentation + + _birefnet_hr_device = 'cuda' if torch.cuda.is_available() else 'cpu' + print(f"Loading BiRefNet-HR model on {_birefnet_hr_device} (first run downloads ~900MB from HuggingFace)") + _birefnet_hr_model = AutoModelForImageSegmentation.from_pretrained( + 'zhengpeng7/BiRefNet_HR', trust_remote_code=True + ) + _birefnet_hr_model.to(_birefnet_hr_device).eval() + print("BiRefNet-HR model loaded") + + original_size = img.size + rgb_img = img.convert('RGB') + + transform = transforms.Compose([ + transforms.Resize((2048, 2048)), + transforms.ToTensor(), + transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), + ]) + input_tensor = transform(rgb_img).unsqueeze(0).to(_birefnet_hr_device) + + with torch.no_grad(): + preds = _birefnet_hr_model(input_tensor)[-1].sigmoid().cpu() + + mask = transforms.ToPILImage()(preds[0].squeeze()).resize(original_size, Image.Resampling.LANCZOS) + + result = rgb_img.convert('RGBA') + result.putalpha(mask) + + buffer = BytesIO() + result.save(buffer, format='PNG') + return buffer.getvalue() + + +@router.post("/remove-background") +async def remove_background( + project_id: Optional[int] = Form(None), + file: Optional[UploadFile] = File(None), + db: Session = Depends(get_db) +): + """ + Remove background from an image using rembg with BiRefNet model. + + Either provide project_id to use current project image, + or upload a file directly. + + Returns PNG with transparent background. + """ + try: + from rembg import remove, new_session + except ImportError: + raise HTTPException( + status_code=500, + detail="rembg not installed. Run: pip install rembg" + ) + + # Get image bytes + if file: + image_bytes = await file.read() + elif project_id: + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + with open(image_path, 'rb') as f: + image_bytes = f.read() + else: + raise HTTPException( + status_code=400, + detail="Provide either project_id or file" + ) + + # Remove background using BiRefNet (state-of-the-art) + try: + session = new_session("birefnet-general") + result_bytes = remove(image_bytes, session=session) + except Exception: + result_bytes = remove(image_bytes) + + return Response( + content=result_bytes, + media_type="image/png", + headers={"Content-Disposition": "inline; filename=no-background.png"} + ) + + +@router.post("/remove-background-to-layer") +async def remove_background_to_layer( + project_id: int = Form(...), + db: Session = Depends(get_db) +): + """ + Remove background using BiRefNet and save as a new layer in the project. + Returns layer info that can be added to frontend layer system. + """ + try: + from rembg import remove, new_session + except ImportError: + raise HTTPException( + status_code=500, + detail="rembg not installed. Run: pip install rembg" + ) + + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + from pathlib import Path + + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + with open(image_path, 'rb') as f: + image_bytes = f.read() + + # Remove background using BiRefNet (state-of-the-art) + try: + session = new_session("birefnet-general") + result_bytes = remove(image_bytes, session=session) + except Exception: + result_bytes = remove(image_bytes) + + # Save as layer file + project_dir = edit_service.get_project_dir(project_id) + layers_dir = project_dir / 'layers' + layers_dir.mkdir(exist_ok=True) + + # Find next layer number + existing_layers = list(layers_dir.glob('layer_*.png')) + layer_num = len(existing_layers) + 1 + layer_path = layers_dir / f'layer_{layer_num}.png' + + with open(layer_path, 'wb') as f: + f.write(result_bytes) + + # Get dimensions + img = Image.open(BytesIO(result_bytes)) + + return { + "status": "success", + "layer": { + "id": layer_num, + "name": f"No Background {layer_num}", + "path": str(layer_path), + "width": img.width, + "height": img.height, + "type": "background_removed" + } + } + + +@router.post("/smart-select") +async def smart_select( + project_id: int = Form(...), + point_x: int = Form(...), + point_y: int = Form(...), + return_format: str = Form("json"), # "json" (default) or "image" + db: Session = Depends(get_db) +): + """ + Use SAM (Segment Anything) to select object at given point. + Returns mask and polygon data for the selected object. + + Note: Requires SAM model to be downloaded. + Falls back to simple flood-fill selection if SAM unavailable. + """ + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + img = Image.open(image_path).convert('RGB') + img_array = np.array(img) + + # Try SAM first, fall back to flood fill + try: + mask = await _sam_select(img_array, point_x, point_y) + except Exception as e: + print(f"SAM not available, using flood fill: {e}") + mask = _flood_fill_select(img_array, point_x, point_y) + + # Convert mask to PNG + mask_img = Image.fromarray((mask * 255).astype(np.uint8), mode='L') + + if return_format == "image": + buffer = BytesIO() + mask_img.save(buffer, format='PNG') + return Response( + content=buffer.getvalue(), + media_type="image/png" + ) + + # Return JSON with polygon and bbox + polygon, bbox = _mask_to_polygon(mask) + + # Also return mask as base64 for potential use + buffer = BytesIO() + mask_img.save(buffer, format='PNG') + mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return { + "polygon": polygon, + "bbox": bbox, + "mask_base64": mask_b64, + } + + +def _mask_to_polygon(mask: np.ndarray) -> tuple: + """ + Convert a binary mask to a simplified polygon and bounding box. + + Returns: + (polygon, bbox) where: + - polygon: list of [x, y] points (simplified contour) + - bbox: dict with x, y, width, height + """ + # Ensure mask is binary uint8 + mask_uint8 = (mask * 255).astype(np.uint8) if mask.max() <= 1 else mask.astype(np.uint8) + + # Find contours + contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + if not contours: + return [], {"x": 0, "y": 0, "width": 0, "height": 0} + + # Get largest contour + largest = max(contours, key=cv2.contourArea) + + # Get bounding box + x, y, w, h = cv2.boundingRect(largest) + bbox = {"x": int(x), "y": int(y), "width": int(w), "height": int(h)} + + # Simplify contour to reduce points (epsilon = 1% of arc length) + epsilon = 0.01 * cv2.arcLength(largest, True) + simplified = cv2.approxPolyDP(largest, epsilon, True) + + # Convert to list of [x, y] points + polygon = [[int(pt[0][0]), int(pt[0][1])] for pt in simplified] + + return polygon, bbox + + +# Global SAM model cache (loaded once, reused) +_sam_model = None +_sam_predictor = None + + +def _get_sam_model(): + """Load SAM model from local file (cached after first load)""" + global _sam_model, _sam_predictor + + if _sam_predictor is not None: + return _sam_predictor + + from pathlib import Path + + # Check for SAM model in models directory + models_dir = Path('/app/data/models') + model_path = models_dir / 'sam_model.pth' + + # Also check for specific model files + if not model_path.exists(): + for filename in ['sam_vit_b_01ec64.pth', 'sam_vit_l_0b3195.pth', 'sam_vit_h_4b8939.pth']: + alt_path = models_dir / filename + if alt_path.exists(): + model_path = alt_path + break + + if not model_path.exists(): + raise FileNotFoundError( + f"SAM model not found. Download it with:\n" + f" docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py" + ) + + # Determine model type from filename + model_type = 'vit_b' # default + if 'vit_l' in model_path.name: + model_type = 'vit_l' + elif 'vit_h' in model_path.name: + model_type = 'vit_h' + + print(f"Loading SAM model: {model_path} (type: {model_type})") + + import torch + from segment_anything import sam_model_registry, SamPredictor + + # Use CPU by default (works everywhere), GPU if available + device = 'cuda' if torch.cuda.is_available() else 'cpu' + + _sam_model = sam_model_registry[model_type](checkpoint=str(model_path)) + _sam_model.to(device) + _sam_predictor = SamPredictor(_sam_model) + + print(f"SAM model loaded on {device}") + return _sam_predictor + + +def _sam_select_local(img_array: np.ndarray, x: int, y: int) -> np.ndarray: + """Use local SAM model for selection (no API calls, runs offline)""" + predictor = _get_sam_model() + + # Set image + predictor.set_image(img_array) + + # Point coordinates (x, y) and label (1 = foreground) + input_point = np.array([[x, y]]) + input_label = np.array([1]) + + # Get mask prediction + masks, scores, _ = predictor.predict( + point_coords=input_point, + point_labels=input_label, + multimask_output=True, # Get multiple mask options + ) + + # Use the mask with highest score + best_mask_idx = np.argmax(scores) + mask = masks[best_mask_idx] + + return mask.astype(np.uint8) + + +async def _sam_select(img_array: np.ndarray, x: int, y: int) -> np.ndarray: + """ + Smart object selection using SAM (Segment Anything Model). + + Priority: + 1. Local SAM model (free, fast, offline) + 2. Replicate API (if local not available and API key set) + 3. Raises exception if neither available + """ + # Try local SAM first (free, no API calls) + try: + return _sam_select_local(img_array, x, y) + except FileNotFoundError as e: + print(f"Local SAM not available: {e}") + except ImportError as e: + print(f"SAM dependencies not installed: {e}") + except Exception as e: + print(f"Local SAM failed: {e}") + + # Fall back to Replicate API + from app.config import settings + + if not settings.replicate_api_key: + raise ValueError( + "SAM model not available. Either:\n" + " 1. Download local model: docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py\n" + " 2. Or set REPLICATE_API_KEY in .env for cloud SAM" + ) + + return await _sam_select_replicate(img_array, x, y) + + +async def _sam_select_replicate(img_array: np.ndarray, x: int, y: int) -> np.ndarray: + """Fallback: Use SAM via Replicate API (requires API key, costs ~$0.002/call)""" + import httpx + import base64 + import asyncio + from app.config import settings + + # Convert image to base64 + img = Image.fromarray(img_array) + buffer = BytesIO() + img.save(buffer, format='PNG') + img_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + async with httpx.AsyncClient(timeout=120.0) as client: + prediction_data = { + "version": "meta/sam-2-image:fe97b453d6525baeeb530595c74a3c4f567c1f655ee2a0fee11f76bd1d31e495", + "input": { + "image": f"data:image/png;base64,{img_b64}", + "point_coords": f"{x},{y}", + "point_labels": "1", + } + } + + headers = { + 'Authorization': f'Bearer {settings.replicate_api_key}', + 'Content-Type': 'application/json' + } + + response = await client.post( + "https://api.replicate.com/v1/predictions", + json=prediction_data, + headers=headers + ) + + if response.status_code != 201: + raise Exception(f"Replicate API error: {response.text}") + + prediction = response.json() + prediction_url = prediction['urls']['get'] + + # Poll for completion + for _ in range(60): + await asyncio.sleep(2) + status_response = await client.get(prediction_url, headers=headers) + status_data = status_response.json() + + if status_data['status'] == 'succeeded': + mask_url = status_data['output'] + if isinstance(mask_url, list): + mask_url = mask_url[0] + + mask_response = await client.get(mask_url) + mask_img = Image.open(BytesIO(mask_response.content)).convert('L') + + if mask_img.size != (img_array.shape[1], img_array.shape[0]): + mask_img = mask_img.resize( + (img_array.shape[1], img_array.shape[0]), + Image.Resampling.LANCZOS + ) + + return np.array(mask_img) // 255 + + elif status_data['status'] == 'failed': + raise Exception(f"SAM prediction failed: {status_data.get('error')}") + + raise Exception("SAM prediction timed out") + + +def _flood_fill_select(img_array: np.ndarray, x: int, y: int, tolerance: int = 32) -> np.ndarray: + """Simple flood-fill based selection with color tolerance""" + import cv2 + + h, w = img_array.shape[:2] + + # Ensure point is within bounds + x = max(0, min(x, w - 1)) + y = max(0, min(y, h - 1)) + + # Create mask for flood fill (needs to be 2 pixels larger) + mask = np.zeros((h + 2, w + 2), np.uint8) + + # Flood fill + cv2.floodFill( + img_array.copy(), + mask, + (x, y), + (255, 255, 255), + (tolerance, tolerance, tolerance), + (tolerance, tolerance, tolerance), + cv2.FLOODFILL_MASK_ONLY + ) + + # Extract the actual mask (remove padding) + return mask[1:-1, 1:-1] + + +@router.post("/color-select") +async def color_select( + project_id: int = Form(...), + color_r: int = Form(...), + color_g: int = Form(...), + color_b: int = Form(...), + tolerance: int = Form(30), + return_format: str = Form("json"), # "json" (default) or "image" + db: Session = Depends(get_db) +): + """ + Select all pixels similar to the given color. + Returns a mask and polygon data for selected areas. + """ + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + img = Image.open(image_path).convert('RGB') + img_array = np.array(img) + + # Target color + target = np.array([color_r, color_g, color_b]) + + # Calculate color distance + diff = np.abs(img_array.astype(np.int16) - target.astype(np.int16)) + distance = np.sum(diff, axis=2) + + # Create mask where distance is within tolerance + mask = (distance <= tolerance * 3).astype(np.uint8) + + # Convert to PNG + mask_img = Image.fromarray(mask * 255, mode='L') + + if return_format == "image": + buffer = BytesIO() + mask_img.save(buffer, format='PNG') + return Response( + content=buffer.getvalue(), + media_type="image/png" + ) + + # Return JSON with polygon and bbox + polygon, bbox = _mask_to_polygon(mask) + + buffer = BytesIO() + mask_img.save(buffer, format='PNG') + mask_b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + + return { + "polygon": polygon, + "bbox": bbox, + "mask_base64": mask_b64, + "color": {"r": color_r, "g": color_g, "b": color_b}, + "tolerance": tolerance, + } + + +@router.post("/extract-object") +async def extract_object( + project_id: int = Form(...), + mask: UploadFile = File(...), + db: Session = Depends(get_db) +): + """ + Extract object using provided mask. + Returns PNG with transparent background containing only the masked area. + """ + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + edit_service = EditService() + image_path = edit_service.get_current_image_path(project_id) + + # Load image and mask + img = Image.open(image_path).convert('RGBA') + mask_bytes = await mask.read() + mask_img = Image.open(BytesIO(mask_bytes)).convert('L') + + # Resize mask if needed + if mask_img.size != img.size: + mask_img = mask_img.resize(img.size, Image.Resampling.LANCZOS) + + # Apply mask as alpha channel + img_array = np.array(img) + mask_array = np.array(mask_img) + + # Set alpha channel based on mask + img_array[:, :, 3] = mask_array + + result = Image.fromarray(img_array, mode='RGBA') + + buffer = BytesIO() + result.save(buffer, format='PNG') + + return Response( + content=buffer.getvalue(), + media_type="image/png" + ) + + +@router.get("/layers/{project_id}") +async def list_layers( + project_id: int, + db: Session = Depends(get_db) +): + """List all layers for a project""" + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + from pathlib import Path + + edit_service = EditService() + project_dir = edit_service.get_project_dir(project_id) + layers_dir = project_dir / 'layers' + + if not layers_dir.exists(): + return {"layers": []} + + layers = [] + for layer_file in sorted(layers_dir.glob('layer_*.png')): + img = Image.open(layer_file) + layer_num = int(layer_file.stem.split('_')[1]) + layers.append({ + "id": layer_num, + "name": f"Layer {layer_num}", + "path": str(layer_file), + "width": img.width, + "height": img.height + }) + + return {"layers": layers} + + +@router.post("/flatten-layers") +async def flatten_layers( + project_id: int = Form(...), + layer_order: str = Form(...), # JSON array of layer IDs in order + db: Session = Depends(get_db) +): + """ + Flatten all layers into a single image and save as current. + layer_order is a JSON array like [1, 2, 3] from bottom to top. + """ + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + from app.services.edit_service import EditService + from pathlib import Path + + edit_service = EditService() + project_dir = edit_service.get_project_dir(project_id) + layers_dir = project_dir / 'layers' + + order = json.loads(layer_order) + + # Start with original image as base + base_path = edit_service.get_current_image_path(project_id) + result = Image.open(base_path).convert('RGBA') + + # Composite layers in order + for layer_id in order: + layer_path = layers_dir / f'layer_{layer_id}.png' + if layer_path.exists(): + layer = Image.open(layer_path).convert('RGBA') + # Resize if needed + if layer.size != result.size: + layer = layer.resize(result.size, Image.Resampling.LANCZOS) + result = Image.alpha_composite(result, layer) + + # Save as current + result.save(base_path, 'PNG') + + return StatusResponse( + status="success", + message="Layers flattened successfully" + ) diff --git a/paintplus/backend/app/schemas.py b/paintplus/backend/app/schemas.py new file mode 100644 index 0000000..2943c69 --- /dev/null +++ b/paintplus/backend/app/schemas.py @@ -0,0 +1,138 @@ +from pydantic import BaseModel, EmailStr +from typing import Optional, List, Dict, Any +from datetime import datetime + + +# User schemas +class UserCreate(BaseModel): + email: EmailStr + password: str + + +class UserResponse(BaseModel): + id: int + email: str + created_at: datetime + + class Config: + from_attributes = True + + +# Project schemas +class ProjectCreate(BaseModel): + name: str + + +class ProjectResponse(BaseModel): + id: int + user_id: int + name: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# Edit schemas +class EditRequest(BaseModel): + prompt: str + mode: str = "A" # "A" or "B" + selection_type: str # "rectangle", "ellipse", "lasso" + bbox: Dict[str, int] # {x, y, width, height} + feather_px: int = 0 + selection_data: Optional[Dict[str, Any]] = None + + +class EditResponse(BaseModel): + id: int + project_id: int + created_at: datetime + mode: str + prompt: str + selection_type: str + bbox_json: str + feather_px: int + ai_provider: str + status: str + error_message: Optional[str] = None + + class Config: + from_attributes = True + + +# Image upload +class UploadResponse(BaseModel): + project_id: int + original_url: str + current_url: str + + +# Patch Library schemas +class PatchCreate(BaseModel): + name: str + description: Optional[str] = None + source_type: str # "ai_generated", "manual_selection", "imported" + source_project_id: Optional[int] = None + source_edit_id: Optional[int] = None + category: Optional[str] = None + tags: Optional[str] = None + bbox: Optional[Dict[str, int]] = None + + +class PatchResponse(BaseModel): + id: int + name: str + description: Optional[str] + created_at: datetime + source_type: str + source_project_id: Optional[int] + source_edit_id: Optional[int] + width: int + height: int + tags: Optional[str] + category: Optional[str] + is_public: bool + file_path: str + thumbnail_path: Optional[str] + + class Config: + from_attributes = True + + +class PatchApply(BaseModel): + project_id: int + patch_id: int + bbox: Dict[str, int] + feather_px: int = 5 + + +# Text-to-Image schemas +class TextToImageRequest(BaseModel): + prompt: str + width: int = 1024 + height: int = 1024 + negative_prompt: Optional[str] = None + ai_provider: Optional[str] = None + ai_model: Optional[str] = None + create_project: bool = True + project_name: Optional[str] = None + + +class TextToImageResponse(BaseModel): + status: str + prompt: str + width: int + height: int + project_id: Optional[int] = None + image_url: Optional[str] = None + layer_position: Optional[Dict[str, int]] = None + ai_provider: str + ai_model: Optional[str] = None + + +# Generic responses +class StatusResponse(BaseModel): + status: str + message: Optional[str] = None + data: Optional[Any] = None diff --git a/paintplus/backend/app/services/__init__.py b/paintplus/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paintplus/backend/app/services/ai_provider.py b/paintplus/backend/app/services/ai_provider.py new file mode 100644 index 0000000..1ca4587 --- /dev/null +++ b/paintplus/backend/app/services/ai_provider.py @@ -0,0 +1,539 @@ +from abc import ABC, abstractmethod +from typing import Optional, Dict +import httpx +import base64 +import asyncio +from io import BytesIO +from app.config import settings + + +class AIProvider(ABC): + """Abstract base class for AI providers""" + + @abstractmethod + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """ + Edit an image patch using AI + + Args: + patch_image_bytes: The cropped patch to edit + mask_image_bytes: Binary mask (same size as patch) + prompt: Text description of desired changes + mode: "A" (patch only) or "B" (patch + full image reference) + full_image_bytes: Full image for context (mode B only) + model: Optional specific model to use + + Returns: + Regenerated patch as bytes + """ + pass + + @abstractmethod + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """ + Generate an image from text prompt + + Args: + prompt: Text description of desired image + width: Image width in pixels + height: Image height in pixels + model: Optional specific model to use + negative_prompt: What to avoid in the generation + + Returns: + Generated image as bytes + """ + pass + + +class OpenAIProvider(AIProvider): + """OpenAI DALL-E 2 based image editing (NOTE: Lower quality than DALL-E 3)""" + + def __init__(self, api_key: str): + self.api_key = api_key + self.base_url = "https://api.openai.com/v1" + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using OpenAI DALL-E 2 (NOTE: Uses older model, lower quality)""" + + async with httpx.AsyncClient(timeout=60.0) as client: + files = { + 'image': ('image.png', patch_image_bytes, 'image/png'), + 'mask': ('mask.png', mask_image_bytes, 'image/png'), + } + + data = { + 'prompt': prompt, + 'n': 1, + 'size': '1024x1024' # Will be adjusted based on input + } + + headers = { + 'Authorization': f'Bearer {self.api_key}' + } + + response = await client.post( + f"{self.base_url}/images/edits", + files=files, + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Download the generated image + image_url = result['data'][0]['url'] + image_response = await client.get(image_url) + image_response.raise_for_status() + + return image_response.content + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using OpenAI DALL-E""" + + async with httpx.AsyncClient(timeout=60.0) as client: + data = { + 'prompt': prompt, + 'n': 1, + 'size': f'{width}x{height}' if width == height else '1024x1024' + } + + headers = { + 'Authorization': f'Bearer {self.api_key}' + } + + response = await client.post( + f"{self.base_url}/images/generations", + json=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Download the generated image + image_url = result['data'][0]['url'] + image_response = await client.get(image_url) + image_response.raise_for_status() + + return image_response.content + + +class StabilityAIProvider(AIProvider): + """Stability AI based image editing (SDXL Inpainting)""" + + # Available Stability AI engines + MODELS = { + 'sdxl': 'stable-diffusion-xl-1024-v1-0', + 'sd15': 'stable-diffusion-v1-5', + 'sd21': 'stable-diffusion-512-v2-1', + } + + def __init__(self, api_key: str, default_model: str = 'sdxl'): + self.api_key = api_key + self.base_url = "https://api.stability.ai/v1" + self.default_model = default_model + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using Stability AI SDXL Inpainting""" + + # Select model + model_key = model or self.default_model + engine_id = self.MODELS.get(model_key, self.MODELS['sdxl']) + + async with httpx.AsyncClient(timeout=120.0) as client: + files = { + 'init_image': ('image.png', patch_image_bytes, 'image/png'), + 'mask_image': ('mask.png', mask_image_bytes, 'image/png'), + } + + # Optimized parameters for better quality + data = { + 'text_prompts[0][text]': prompt, + 'text_prompts[0][weight]': '1.0', + 'cfg_scale': '8', # Increased for better prompt adherence + 'samples': '1', + 'steps': '40', # Increased for better quality + 'mask_source': 'MASK_IMAGE_WHITE', # White areas are inpainted + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'application/json' + } + + response = await client.post( + f"{self.base_url}/generation/{engine_id}/image-to-image/masking", + files=files, + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Decode base64 image + image_data = result['artifacts'][0]['base64'] + return base64.b64decode(image_data) + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using Stability AI SDXL""" + + # Select model + model_key = model or self.default_model + engine_id = self.MODELS.get(model_key, self.MODELS['sdxl']) + + async with httpx.AsyncClient(timeout=120.0) as client: + # Build prompts array + data = { + 'text_prompts[0][text]': prompt, + 'text_prompts[0][weight]': '1.0', + 'cfg_scale': '7', + 'samples': '1', + 'steps': '50', + 'height': str(height), + 'width': str(width), + } + + # Add negative prompt if provided + if negative_prompt: + data['text_prompts[1][text]'] = negative_prompt + data['text_prompts[1][weight]'] = '-1.0' + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Accept': 'application/json' + } + + response = await client.post( + f"{self.base_url}/generation/{engine_id}/text-to-image", + data=data, + headers=headers + ) + + response.raise_for_status() + result = response.json() + + # Decode base64 image + image_data = result['artifacts'][0]['base64'] + return base64.b64decode(image_data) + + +class ReplicateProvider(AIProvider): + """Replicate API with multiple model support""" + + # Available Replicate models for inpainting + MODELS = { + # SDXL Inpainting - Best general purpose + 'sdxl-inpaint': { + 'version': 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b', + 'use_case': 'General purpose, high quality', + 'cost': '~$0.025/image', + 'best_for': ['general', 'landscapes', 'objects', 'textures'] + }, + # LaMa - Best for object removal + 'lama': { + 'version': 'andreasjansson/lama:7f4a2e3c95ab83c1d66ea26a66c27f93b64a2e5a3c5f7f4f4f4f4f4f4f4f4f4f', + 'use_case': 'Object removal and cleanup', + 'cost': '~$0.002/image', + 'best_for': ['removal', 'cleanup', 'erase'] + }, + # Realistic Vision - Best for human features (faces, bodies, hands) + 'realistic-vision': { + 'version': 'stability-ai/stable-diffusion:db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf', + 'use_case': 'Human features, realistic photos', + 'cost': '~$0.020/image', + 'best_for': ['face', 'body', 'hands', 'portrait', 'person', 'human'] + }, + } + + def __init__(self, api_key: str, default_model: str = 'sdxl-inpaint'): + self.api_key = api_key + self.base_url = "https://api.replicate.com/v1" + self.default_model = default_model + + def _select_model_from_prompt(self, prompt: str) -> str: + """Auto-select best model based on prompt keywords""" + prompt_lower = prompt.lower() + + # Check for removal/cleanup keywords + if any(word in prompt_lower for word in ['remove', 'erase', 'delete', 'cleanup']): + return 'lama' + + # Check for human feature keywords + if any(word in prompt_lower for word in ['hand', 'face', 'body', 'person', 'portrait', 'skin']): + return 'realistic-vision' + + # Default to SDXL for general purpose + return 'sdxl-inpaint' + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Edit image using Replicate with auto model selection""" + + # Auto-select model if not specified + if not model: + model = self._select_model_from_prompt(prompt) + + model_config = self.MODELS.get(model, self.MODELS['sdxl-inpaint']) + + # Convert bytes to base64 for Replicate API + patch_b64 = base64.b64encode(patch_image_bytes).decode('utf-8') + mask_b64 = base64.b64encode(mask_image_bytes).decode('utf-8') + + async with httpx.AsyncClient(timeout=120.0) as client: + # Create prediction + prediction_data = { + "version": model_config['version'], + "input": { + "image": f"data:image/png;base64,{patch_b64}", + "mask": f"data:image/png;base64,{mask_b64}", + "prompt": prompt, + "num_outputs": 1, + "guidance_scale": 7.5, + "num_inference_steps": 50, + } + } + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # Start prediction + response = await client.post( + f"{self.base_url}/predictions", + json=prediction_data, + headers=headers + ) + response.raise_for_status() + prediction = response.json() + + # Poll for completion + prediction_url = prediction['urls']['get'] + max_attempts = 60 # 2 minutes max + attempt = 0 + + while attempt < max_attempts: + await asyncio.sleep(2) # Wait 2 seconds between polls + + status_response = await client.get(prediction_url, headers=headers) + status_response.raise_for_status() + status_data = status_response.json() + + if status_data['status'] == 'succeeded': + # Download result image + output_url = status_data['output'][0] + image_response = await client.get(output_url) + image_response.raise_for_status() + return image_response.content + + elif status_data['status'] == 'failed': + raise Exception(f"Replicate prediction failed: {status_data.get('error')}") + + attempt += 1 + + raise Exception("Replicate prediction timed out") + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate image using Replicate SDXL""" + + # Use SDXL for text-to-image + model_version = 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b' + + async with httpx.AsyncClient(timeout=120.0) as client: + # Create prediction + prediction_data = { + "version": model_version, + "input": { + "prompt": prompt, + "width": width, + "height": height, + "num_outputs": 1, + "guidance_scale": 7.5, + "num_inference_steps": 50, + } + } + + # Add negative prompt if provided + if negative_prompt: + prediction_data["input"]["negative_prompt"] = negative_prompt + + headers = { + 'Authorization': f'Bearer {self.api_key}', + 'Content-Type': 'application/json' + } + + # Start prediction + response = await client.post( + f"{self.base_url}/predictions", + json=prediction_data, + headers=headers + ) + response.raise_for_status() + prediction = response.json() + + # Poll for completion + prediction_url = prediction['urls']['get'] + max_attempts = 60 + attempt = 0 + + while attempt < max_attempts: + await asyncio.sleep(2) + + status_response = await client.get(prediction_url, headers=headers) + status_response.raise_for_status() + status_data = status_response.json() + + if status_data['status'] == 'succeeded': + # Download result image + output_url = status_data['output'][0] + image_response = await client.get(output_url) + image_response.raise_for_status() + return image_response.content + + elif status_data['status'] == 'failed': + raise Exception(f"Replicate prediction failed: {status_data.get('error')}") + + attempt += 1 + + raise Exception("Replicate text-to-image timed out") + + +class MockAIProvider(AIProvider): + """Mock provider for testing (returns original patch)""" + + async def edit_image( + self, + patch_image_bytes: bytes, + mask_image_bytes: bytes, + prompt: str, + mode: str, + full_image_bytes: Optional[bytes] = None, + model: Optional[str] = None + ) -> bytes: + """Return the original patch (for testing)""" + return patch_image_bytes + + async def text_to_image( + self, + prompt: str, + width: int = 1024, + height: int = 1024, + model: Optional[str] = None, + negative_prompt: Optional[str] = None + ) -> bytes: + """Generate a placeholder image (for testing)""" + from PIL import Image, ImageDraw, ImageFont + + # Create a simple placeholder image + img = Image.new('RGB', (width, height), color='lightgray') + draw = ImageDraw.Draw(img) + + # Draw text + text = f"Mock Image\n{width}x{height}\n{prompt[:50]}" + draw.text((width//4, height//2), text, fill='black') + + # Convert to bytes + buffer = BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() + + +def get_ai_provider(provider_name: Optional[str] = None, model: Optional[str] = None) -> AIProvider: + """ + Factory function to get the configured AI provider + + Args: + provider_name: Override default provider from settings + model: Specific model to use (provider-dependent) + + Returns: + AIProvider instance + """ + + provider = provider_name or settings.ai_provider + provider = provider.lower() + + if provider == "openai": + if not settings.openai_api_key: + raise ValueError("OpenAI API key not configured") + return OpenAIProvider(settings.openai_api_key) + + elif provider == "stability": + if not settings.stability_api_key: + raise ValueError("Stability AI API key not configured") + default_model = model or getattr(settings, 'stability_model', 'sdxl') + return StabilityAIProvider(settings.stability_api_key, default_model=default_model) + + elif provider == "replicate": + if not settings.replicate_api_key: + raise ValueError("Replicate API key not configured") + default_model = model or getattr(settings, 'replicate_model', 'sdxl-inpaint') + return ReplicateProvider(settings.replicate_api_key, default_model=default_model) + + elif provider == "mock": + return MockAIProvider() + + else: + raise ValueError(f"Unknown AI provider: {provider}") diff --git a/paintplus/backend/app/services/edit_service.py b/paintplus/backend/app/services/edit_service.py new file mode 100644 index 0000000..4007b9d --- /dev/null +++ b/paintplus/backend/app/services/edit_service.py @@ -0,0 +1,220 @@ +import os +import json +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime +from PIL import Image + +from app.models.edit import Edit +from app.models.project import Project +from app.services.ai_provider import get_ai_provider +from app.utils.image_processing import ( + bytes_to_image, + image_to_bytes, + crop_patch, + blend_patch, + insert_patch, + create_mask_from_selection, + resize_for_ai, + scale_bbox +) +from app.config import settings + + +class EditService: + """Service for handling image edits""" + + def __init__(self, data_dir: str = None): + self.data_dir = data_dir or settings.data_dir + self.ai_provider = get_ai_provider() + + def get_project_dir(self, project_id: int) -> Path: + """Get project directory path""" + return Path(self.data_dir) / "projects" / str(project_id) + + def get_edit_dir(self, project_id: int, edit_id: int) -> Path: + """Get edit history directory path""" + return self.get_project_dir(project_id) / "history" / str(edit_id) + + def ensure_project_dir(self, project_id: int): + """Ensure project directory structure exists""" + project_dir = self.get_project_dir(project_id) + project_dir.mkdir(parents=True, exist_ok=True) + (project_dir / "history").mkdir(exist_ok=True) + + def get_current_image_path(self, project_id: int) -> Path: + """Get path to current image""" + return self.get_project_dir(project_id) / "current.png" + + def get_original_image_path(self, project_id: int) -> Path: + """Get path to original image""" + return self.get_project_dir(project_id) / "original.png" + + async def process_edit( + self, + project_id: int, + edit_id: int, + prompt: str, + mode: str, + selection_type: str, + bbox: Dict[str, int], + feather_px: int, + selection_data: Optional[Dict] = None + ) -> str: + """ + Process an edit request + + Args: + project_id: Project ID + edit_id: Edit ID + prompt: AI prompt + mode: "A" or "B" + selection_type: "rectangle", "ellipse", or "lasso" + bbox: Bounding box {x, y, width, height} + feather_px: Feather radius in pixels + selection_data: Additional selection data (for lasso) + + Returns: + Path to the result image + """ + # Create edit directory + edit_dir = self.get_edit_dir(project_id, edit_id) + edit_dir.mkdir(parents=True, exist_ok=True) + + # Load current image + current_image_path = self.get_current_image_path(project_id) + full_image = Image.open(current_image_path).convert('RGBA') + + # Crop patch from current image + original_patch = crop_patch(full_image, bbox) + + # Save original patch + original_patch.save(edit_dir / "patch_in.png") + + # Create mask based on selection type + mask = create_mask_from_selection( + bbox['width'], + bbox['height'], + selection_type, + selection_data or {} + ) + + # Save mask + mask.save(edit_dir / "mask.png") + + # Resize patch and mask for AI if needed + patch_for_ai, scale = resize_for_ai(original_patch) + mask_for_ai = mask.resize(patch_for_ai.size, Image.Resampling.LANCZOS) + + # Prepare full image for mode B + full_image_bytes = None + if mode == "B": + full_image_for_ai, _ = resize_for_ai(full_image) + full_image_bytes = image_to_bytes(full_image_for_ai) + + # Call AI provider + regenerated_patch_bytes = await self.ai_provider.edit_image( + patch_image_bytes=image_to_bytes(patch_for_ai), + mask_image_bytes=image_to_bytes(mask_for_ai), + prompt=prompt, + mode=mode, + full_image_bytes=full_image_bytes + ) + + # Convert regenerated patch back to PIL Image + regenerated_patch = bytes_to_image(regenerated_patch_bytes) + + # Resize back to original patch size if scaled + if scale != 1.0: + regenerated_patch = regenerated_patch.resize( + original_patch.size, + Image.Resampling.LANCZOS + ) + + # Save regenerated patch + regenerated_patch.save(edit_dir / "patch_out.png") + + # Blend regenerated patch with original using mask + blended_patch = blend_patch( + original_patch, + regenerated_patch, + mask, + feather_px + ) + + # Insert blended patch back into full image + result_image = insert_patch(full_image, blended_patch, bbox) + + # Save result + result_path = edit_dir / "result.png" + result_image.save(result_path) + + # Update current image + result_image.save(current_image_path) + + # Save metadata + metadata = { + 'edit_id': edit_id, + 'project_id': project_id, + 'prompt': prompt, + 'mode': mode, + 'selection_type': selection_type, + 'bbox': bbox, + 'feather_px': feather_px, + 'selection_data': selection_data, + 'timestamp': datetime.utcnow().isoformat(), + 'ai_provider': settings.ai_provider + } + + with open(edit_dir / "meta.json", 'w') as f: + json.dump(metadata, f, indent=2) + + return str(result_path) + + def revert_to_edit(self, project_id: int, edit_id: int) -> str: + """ + Revert project to a specific edit + + Args: + project_id: Project ID + edit_id: Edit ID to revert to + + Returns: + Path to the reverted image + """ + edit_dir = self.get_edit_dir(project_id, edit_id) + result_path = edit_dir / "result.png" + + if not result_path.exists(): + raise FileNotFoundError(f"Edit {edit_id} result not found") + + # Copy result to current (preserve alpha channel) + current_path = self.get_current_image_path(project_id) + img = Image.open(result_path) + # Preserve original mode to maintain transparency + img.save(current_path, format='PNG') + + return str(current_path) + + def reset_to_original(self, project_id: int) -> str: + """ + Reset project to original image + + Args: + project_id: Project ID + + Returns: + Path to the original image + """ + original_path = self.get_original_image_path(project_id) + current_path = self.get_current_image_path(project_id) + + if not original_path.exists(): + raise FileNotFoundError(f"Original image for project {project_id} not found") + + # Copy original to current (preserve alpha channel) + img = Image.open(original_path) + # Preserve original mode to maintain transparency + img.save(current_path, format='PNG') + + return str(current_path) diff --git a/paintplus/backend/app/services/gpu_detect.py b/paintplus/backend/app/services/gpu_detect.py new file mode 100644 index 0000000..e4d495f --- /dev/null +++ b/paintplus/backend/app/services/gpu_detect.py @@ -0,0 +1,391 @@ +""" +GPU capability detection and per-operation model selection. + +Probes the actual GPU — VRAM (total + free), CUDA compute capability, and +feature flags (fp16, bf16, fp8, int8, tensor cores) — then selects the +highest-quality model that fits for each operation. + +Model selection ladder (txt2img): + eff_vram ≥ 20 GB → FLUX.1-schnell (no offload) + eff_vram ≥ 10 GB → FLUX.1-schnell (model_cpu_offload, 2–3× slower but fits) + eff_vram ≥ 7.5 GB → SDXL base + eff_vram ≥ 5.5 GB → SDXL base + attention slicing + eff_vram ≥ 4.0 GB → SDXL + model_cpu_offload (GTX 1060 6 GB, Quadro 6 GB) + eff_vram ≥ 3.5 GB → Stable Diffusion 2.1 + eff_vram ≥ 2.5 GB → SD 2.1-base + attention slicing + eff_vram ≥ 1.7 GB → Stable Diffusion 1.5 + otherwise → SD 1.5 + sequential CPU offload + +Inpaint always uses SDXL/SD-family (no FLUX inpaint pipeline yet). +""" +from __future__ import annotations + +import subprocess +from dataclasses import dataclass, field +from typing import Optional + + +# ── Model specification ─────────────────────────────────────────────────────── + +@dataclass +class ModelSpec: + """Everything needed to load and run one diffusion pipeline.""" + model_id: str + family: str # sd15 | sd2x | sdxl | flux + memory_opt: str # none | attention_slicing | model_cpu_offload | sequential_cpu_offload + native_res: int # 512 | 768 | 1024 + vram_fp16_gb: float # approx VRAM needed in fp16, no memory opts + + +# ── GPU capability record ───────────────────────────────────────────────────── + +@dataclass +class GpuCapabilities: + # Hardware + backend: str # cuda | mps | cpu + device_name: str + vram_total_gb: float + vram_free_gb: float + compute_capability: str # "8.6", "7.5", "6.1" … + cc_major: int + cc_minor: int + + # Feature flags derived from compute capability + fp16: bool # reliable fp16 (CC ≥ 6.0; CC 5.x works but slower) + bf16: bool # native bf16 (CC ≥ 8.0) + fp8: bool # native fp8 (CC ≥ 8.9, Ada / Hopper) + int8: bool # efficient int8 (CC ≥ 7.0, needed for bitsandbytes) + tensor_cores: bool # tensor cores (CC ≥ 7.0, Volta+) + xformers: bool # xformers installed (reduces attention VRAM ~20-30%) + + # Derived budget + effective_vram_gb: float # free VRAM after overhead, halved if fp32-only + + # Human-readable tier label + tier: str # flux_full | flux_offload | sdxl | sdxl_low | sdxl_offload | sd2x | sd2x_low | sd15 | minimal + + # Best model per operation + recommended: dict[str, Optional[ModelSpec]] + + # Metadata + warnings: list[str] + capabilities: list[str] + + +# ── Detection ───────────────────────────────────────────────────────────────── + +def detect_gpu() -> GpuCapabilities: + """Probe the GPU, return a fully populated GpuCapabilities.""" + try: + import torch + + if torch.cuda.is_available(): + props = torch.cuda.get_device_properties(0) + free_bytes, total_bytes = torch.cuda.mem_get_info(0) + vram_total = total_bytes / (1024 ** 3) + vram_free = free_bytes / (1024 ** 3) + cc = f"{props.major}.{props.minor}" + major, minor = props.major, props.minor + + fp16 = major >= 6 # Pascal and newer have good fp16 + bf16 = major >= 8 # Ampere A100 / RTX 3000+ + fp8 = major > 8 or (major == 8 and minor >= 9) # Ada / Hopper + int8 = major >= 7 # Volta+ + tensor_cores = major >= 7 + + # Pre-Pascal (Maxwell CC 5.x): fp16 works but throughput is lower than fp32 + # on some Maxwell cards. Flag it so memory opt logic can account for it. + xf = _xformers_available() + + # Subtract driver/CUDA context overhead from free VRAM + overhead_gb = 0.4 + eff = max(0.0, vram_free - overhead_gb) + if not fp16: + eff /= 2.0 # fp32 weights are 2× larger + + tier = _tier_label(eff) + warnings = _build_warnings( + tier, vram_total, vram_free, cc, major, minor, fp16, bf16, fp8, xf + ) + + return GpuCapabilities( + backend="cuda", + device_name=props.name, + vram_total_gb=round(vram_total, 1), + vram_free_gb=round(vram_free, 1), + compute_capability=cc, + cc_major=major, + cc_minor=minor, + fp16=fp16, + bf16=bf16, + fp8=fp8, + int8=int8, + tensor_cores=tensor_cores, + xformers=xf, + effective_vram_gb=round(eff, 1), + tier=tier, + recommended=_select_all_models(eff), + warnings=warnings, + capabilities=_caps(tier), + ) + + if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + usable_gb = _apple_usable_gb() + eff = max(0.0, usable_gb - 0.5) + tier = _tier_label(eff) + return GpuCapabilities( + backend="mps", + device_name="Apple Silicon", + vram_total_gb=round(usable_gb, 1), + vram_free_gb=round(usable_gb, 1), + compute_capability="mps", + cc_major=0, + cc_minor=0, + fp16=False, # MPS diffusion more stable in fp32 + bf16=False, + fp8=False, + int8=False, + tensor_cores=False, + xformers=False, + effective_vram_gb=round(eff / 2, 1), # fp32 on MPS + tier=tier, + recommended=_select_all_models(eff / 2), + warnings=["Apple MPS: using fp32 (fp16 less stable). Models load slower."], + capabilities=_caps(tier), + ) + + except ImportError: + pass + + # CPU fallback + return GpuCapabilities( + backend="cpu", + device_name="CPU (no GPU)", + vram_total_gb=0.0, + vram_free_gb=0.0, + compute_capability="", + cc_major=0, cc_minor=0, + fp16=False, bf16=False, fp8=False, int8=False, + tensor_cores=False, xformers=False, + effective_vram_gb=0.0, + tier="minimal", + recommended=_select_all_models(0.0), + warnings=[ + "No GPU found. Running on CPU — expect 5–30 minutes per image. " + "Consider setting AI_PROVIDER to a remote/cloud provider instead." + ], + capabilities=["txt2img", "inpaint", "img2img", "outpaint"], + ) + + +# ── Model selection ─────────────────────────────────────────────────────────── + +def _select_all_models(eff_vram: float) -> dict[str, Optional[ModelSpec]]: + return { + "txt2img": _select_txt2img(eff_vram), + "img2img": _select_img2img(eff_vram), + "inpaint": _select_inpaint(eff_vram), + "outpaint": _select_inpaint(eff_vram), # shares inpaint pipeline + "upscale": _select_upscale(eff_vram), + } + + +def _select_txt2img(eff: float) -> ModelSpec: + # FLUX.1-schnell (Apache 2.0, 4-step distilled) + if eff >= 20.0: + return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "none", 1024, 20.0) + if eff >= 10.0: + return ModelSpec("black-forest-labs/FLUX.1-schnell", "flux", "model_cpu_offload", 1024, 20.0) + # SDXL base + if eff >= 7.5: + return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "none", 1024, 6.5) + if eff >= 5.5: + return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "attention_slicing", 1024, 6.5) + if eff >= 4.0: + return ModelSpec("stabilityai/stable-diffusion-xl-base-1.0", "sdxl", "model_cpu_offload", 1024, 6.5) + # SD 2.x + if eff >= 3.5: + return ModelSpec("stabilityai/stable-diffusion-2-1", "sd2x", "none", 768, 3.5) + if eff >= 2.5: + return ModelSpec("stabilityai/stable-diffusion-2-1-base", "sd2x", "attention_slicing", 512, 3.2) + # SD 1.5 + if eff >= 1.7: + return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "attention_slicing", 512, 1.7) + return ModelSpec("stable-diffusion-v1-5/stable-diffusion-v1-5", "sd15", "sequential_cpu_offload", 512, 1.7) + + +def _select_img2img(eff: float) -> ModelSpec: + # img2img uses the same model family as txt2img + s = _select_txt2img(eff) + # FLUX img2img uses a different pipeline class but same model weights + return s + + +def _select_inpaint(eff: float) -> ModelSpec: + # No FLUX inpaint pipeline available yet — SDXL is the ceiling + if eff >= 7.5: + return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "none", 1024, 6.5) + if eff >= 5.5: + return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "attention_slicing", 1024, 6.5) + if eff >= 4.0: + return ModelSpec("diffusers/stable-diffusion-xl-1.0-inpainting-0.1", "sdxl", "model_cpu_offload", 1024, 6.5) + if eff >= 3.5: + return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "none", 512, 3.5) + if eff >= 2.5: + return ModelSpec("stabilityai/stable-diffusion-2-inpainting", "sd2x", "attention_slicing", 512, 3.5) + if eff >= 1.7: + return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "attention_slicing", 512, 1.7) + return ModelSpec("runwayml/stable-diffusion-inpainting", "sd15", "sequential_cpu_offload", 512, 1.7) + + +def _select_upscale(eff: float) -> Optional[ModelSpec]: + # SD x4 upscaler — needs ~2 GB fp16 PLUS headroom for the loaded inpaint/txt2img model. + # Only enable if eff_vram suggests room for it as a secondary pipeline. + if eff >= 6.0: + return ModelSpec("stabilityai/stable-diffusion-x4-upscaler", "sd2x", "attention_slicing", 512, 2.0) + return None # fall through to Real-ESRGAN + + +# ── Tier label (display only) ───────────────────────────────────────────────── + +def _tier_label(eff_vram: float) -> str: + if eff_vram >= 20: return "flux_full" + if eff_vram >= 10: return "flux_offload" + if eff_vram >= 7.5: return "sdxl" + if eff_vram >= 5.5: return "sdxl_low" + if eff_vram >= 4.0: return "sdxl_offload" + if eff_vram >= 3.5: return "sd2x" + if eff_vram >= 2.5: return "sd2x_low" + if eff_vram >= 1.7: return "sd15" + return "minimal" + + +def _caps(tier: str) -> list[str]: + base = ["txt2img", "inpaint", "img2img", "outpaint"] + if tier in ("flux_full", "flux_offload", "sdxl", "sdxl_low", "sdxl_offload"): + return base + ["upscale_diffusion"] + return base + + +# ── Warnings ────────────────────────────────────────────────────────────────── + +def _build_warnings( + tier: str, vram_total: float, vram_free: float, + cc: str, major: int, minor: int, + fp16: bool, bf16: bool, fp8: bool, xf: bool, +) -> list[str]: + w = [] + + if major < 5: + w.append( + f"GPU compute capability {cc} is not supported by PyTorch 2.x. " + "Upgrade to a Kepler/Maxwell-era or newer GPU (CC ≥ 5.0)." + ) + elif major < 6: + w.append( + f"GPU is Maxwell-era (CC {cc}). fp32 mode — models need 2× VRAM. " + "A Pascal GTX 1000-series or newer card enables fp16." + ) + elif not bf16 and tier in ("flux_full", "flux_offload"): + w.append( + f"GPU CC {cc}: FLUX runs in fp16 (bf16 needs CC ≥ 8.0). " + "Results are still good but Ampere/Ada GPUs are faster here." + ) + + if fp8 and tier in ("flux_full", "flux_offload"): + w.append( + "FP8 native support detected (Ada Lovelace / Hopper). " + "Set HF_MODEL_TXT2IMG=flux-community/flux.1-schnell-fp8 for ~40% VRAM reduction." + ) + + if tier == "minimal": + w.append( + f"Very low effective VRAM ({vram_free:.1f} GB free). " + "Sequential CPU offload will be used — expect 10–30 min per image." + ) + elif tier == "sdxl_offload": + w.append( + f"Limited VRAM ({vram_free:.1f} GB free). " + "Using SDXL with model_cpu_offload — better quality than SD 2.x, ~30% slower. " + "Install xformers or upgrade to ≥5.5 GB effective VRAM for full-speed SDXL." + ) + elif tier in ("sd15", "sd2x_low"): + w.append( + f"Limited VRAM ({vram_free:.1f} GB free). " + "Using SD 1.5/2.x. Upgrade to ≥5.5 GB free for SDXL quality." + ) + + if xf: + w.append( + "xformers detected — attention VRAM reduced ~20-30%. " + "You may be able to run a higher-tier model than listed." + ) + else: + if tier in ("sdxl_low", "sdxl_offload", "sd2x"): + w.append( + "xformers not installed. Install it (pip install xformers) to reduce " + "VRAM usage ~20-30% and potentially unlock the next model tier." + ) + + return w + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _xformers_available() -> bool: + try: + import xformers # noqa: F401 + return True + except ImportError: + return False + + +def _apple_usable_gb() -> float: + """Estimate GPU-usable unified memory (≈ half of total RAM).""" + try: + r = subprocess.run( + ["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5 + ) + if r.returncode == 0: + return int(r.stdout.strip()) / (1024 ** 3) / 2 + except Exception: + pass + return 8.0 + + +def infer_spec_from_model_id(model_id: str) -> ModelSpec: + """ + When the user supplies HF_MODEL_* overrides, infer the pipeline family + from naming conventions so the correct diffusers class is chosen. + """ + mid = model_id.lower() + if "flux" in mid: + return ModelSpec(model_id, "flux", "model_cpu_offload", 1024, 20.0) + if "xl" in mid or "sdxl" in mid: + return ModelSpec(model_id, "sdxl", "attention_slicing", 1024, 6.5) + if any(x in mid for x in ["sd-2", "sd2", "stable-diffusion-2", "-2-", "-2inpaint"]): + res = 512 if "base" in mid else 768 + return ModelSpec(model_id, "sd2x", "attention_slicing", res, 3.5) + return ModelSpec(model_id, "sd15", "attention_slicing", 512, 1.7) + + +# ── Singleton ───────────────────────────────────────────────────────────────── + +_cached: Optional[GpuCapabilities] = None + + +def get_cached_gpu_info() -> GpuCapabilities: + global _cached + if _cached is None: + _cached = detect_gpu() + return _cached + + +# Alias kept for any callers still using the old name +def get_model_ids(tier: str) -> dict: + """Compatibility shim — returns model_id strings keyed by operation.""" + info = get_cached_gpu_info() + return { + op: (spec.model_id if spec else None) + for op, spec in info.recommended.items() + } diff --git a/paintplus/backend/app/services/local_diffusion.py b/paintplus/backend/app/services/local_diffusion.py new file mode 100644 index 0000000..2e4312e --- /dev/null +++ b/paintplus/backend/app/services/local_diffusion.py @@ -0,0 +1,584 @@ +""" +Local GPU diffusion provider — HuggingFace Diffusers backend. + +Implements RemoteAIProvider so all existing routes work unchanged. +Pipelines are lazy-loaded, cached in an LRU store, and memory-optimised +per the ModelSpec chosen by gpu_detect. + +Supported model families: + flux → FluxPipeline / FluxImg2ImgPipeline (FLUX.1-schnell) + sdxl → StableDiffusionXL*Pipeline (SDXL base + SDXL Inpaint) + sd2x → StableDiffusion2*Pipeline (SD 2.x) + sd15 → StableDiffusionPipeline (SD 1.5) + +Requires: diffusers>=0.28.0,<0.29.0, transformers, accelerate, safetensors + (all in requirements.gpu.txt — pinned <0.29.0 for PyTorch 2.1.x compatibility) +""" +from __future__ import annotations + +import asyncio +import threading +from collections import OrderedDict +from io import BytesIO +from typing import Optional + +from PIL import Image + +from app.services.gpu_detect import ( + GpuCapabilities, + ModelSpec, + get_cached_gpu_info, + infer_spec_from_model_id, +) +from app.services.remote_provider import RemoteAIProvider + +# ── Model state tracking ────────────────────────────────────────────────────── + +_states: dict[str, dict] = {} +_states_lock = threading.Lock() + + +def _set_state(key: str, **kw): + with _states_lock: + _states.setdefault(key, {}).update(kw) + + +def get_all_model_states() -> list[dict]: + with _states_lock: + return list(_states.values()) + + +def _make_step_cb(pipe_type: str, total_steps: int): + """ + Returns a diffusers callback_on_step_end that writes per-step progress + into _states so the SSE /api/generate/progress endpoint can stream it. + Called from a thread executor — _set_state is thread-safe. + """ + def cb(pipe, step_index: int, timestep, callback_kwargs: dict) -> dict: + done = step_index + 1 + _set_state(pipe_type, + state="running", + step=done, + total_steps=total_steps, + progress=round(done / total_steps * 85, 1), + message=f"Step {done} / {total_steps}") + return callback_kwargs + return cb + + +# ── LRU pipeline cache ──────────────────────────────────────────────────────── + +class _PipelineCache: + def __init__(self, maxsize: int = 2): + self._cache: OrderedDict[str, object] = OrderedDict() + self._maxsize = maxsize + self._lock = asyncio.Lock() + + async def get(self, key: str): + async with self._lock: + if key in self._cache: + self._cache.move_to_end(key) + return self._cache[key] + return None + + async def put(self, key: str, pipe: object): + async with self._lock: + if key in self._cache: + self._cache.move_to_end(key) + else: + if len(self._cache) >= self._maxsize: + evicted_key, evicted = self._cache.popitem(last=False) + _evict(evicted, evicted_key) + self._cache[key] = pipe + + +def _evict(pipe, key: str): + try: + import torch + pipe.to("cpu") + torch.cuda.empty_cache() + print(f"[local_gpu] Evicted '{key}' from GPU cache") + except Exception: + pass + + +# ── Pipeline loading helpers ────────────────────────────────────────────────── + +def _apply_hf_token(): + try: + from app.config import settings + if settings.hf_token: + import huggingface_hub + huggingface_hub.login(token=settings.hf_token, add_to_git_credential=False) + except Exception: + pass + + +def _get_spec(pipe_type: str, info: GpuCapabilities) -> ModelSpec: + """Return the ModelSpec for a pipeline type, respecting user overrides.""" + # Map outpaint to inpaint (same pipeline) + op_key = "inpaint" if pipe_type == "outpaint" else pipe_type + # img2img uses same family/model as txt2img for FLUX/SDXL + if pipe_type == "img2img" and op_key not in info.recommended: + op_key = "txt2img" + + # User config override + try: + from app.config import settings + override_map = { + "inpaint": settings.hf_model_inpaint, + "outpaint": settings.hf_model_inpaint, + "txt2img": settings.hf_model_txt2img, + "img2img": settings.hf_model_img2img, + } + override_id = override_map.get(pipe_type, "") or "" + if override_id: + return infer_spec_from_model_id(override_id) + except Exception: + pass + + spec = info.recommended.get(op_key) + if spec is None: + raise RuntimeError( + f"No model available for '{pipe_type}' at effective VRAM " + f"{info.effective_vram_gb:.1f} GB. GPU may not have enough memory." + ) + return spec + + +def _load_sd_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object: + """Load a Stable Diffusion (1.5 / 2.x / XL) pipeline.""" + import torch + from diffusers import ( + StableDiffusionPipeline, + StableDiffusionImg2ImgPipeline, + StableDiffusionInpaintPipeline, + StableDiffusionUpscalePipeline, + StableDiffusionXLPipeline, + StableDiffusionXLImg2ImgPipeline, + StableDiffusionXLInpaintPipeline, + ) + + dtype = torch.float16 if info.fp16 else torch.float32 + is_xl = spec.family == "sdxl" + kwargs: dict = {"torch_dtype": dtype} + if not is_xl: + kwargs["safety_checker"] = None + kwargs["requires_safety_checker"] = False + + op_key = "inpaint" if pipe_type in ("inpaint", "outpaint") else pipe_type + + if op_key == "inpaint": + cls = StableDiffusionXLInpaintPipeline if is_xl else StableDiffusionInpaintPipeline + elif op_key == "txt2img": + cls = StableDiffusionXLPipeline if is_xl else StableDiffusionPipeline + elif op_key == "img2img": + cls = StableDiffusionXLImg2ImgPipeline if is_xl else StableDiffusionImg2ImgPipeline + elif op_key == "upscale": + cls = StableDiffusionUpscalePipeline + else: + raise ValueError(f"Unknown SD operation: {op_key}") + + pipe = cls.from_pretrained(spec.model_id, **kwargs) + return _apply_mem_opts(pipe, spec, info) + + +def _load_flux_pipeline(pipe_type: str, spec: ModelSpec, info: GpuCapabilities) -> object: + """Load a FLUX pipeline (txt2img or img2img).""" + import torch + from diffusers import FluxPipeline, FluxImg2ImgPipeline + + # FLUX works best in bf16 on Ampere+; fp16 on older Turing/Pascal + dtype = torch.bfloat16 if info.bf16 else torch.float16 + + op_key = "img2img" if pipe_type == "img2img" else "txt2img" + cls = FluxImg2ImgPipeline if op_key == "img2img" else FluxPipeline + + pipe = cls.from_pretrained(spec.model_id, torch_dtype=dtype) + return _apply_mem_opts(pipe, spec, info) + + +def _apply_mem_opts(pipe, spec: ModelSpec, info: GpuCapabilities) -> object: + """Apply memory optimisations then move pipeline to device.""" + device = info.backend + opt = spec.memory_opt + + # VAE slicing is always beneficial (reduces VRAM for decoding large images) + try: + pipe.enable_vae_slicing() + except Exception: + pass + + # xformers memory-efficient attention + if info.xformers and spec.family != "flux": + try: + pipe.enable_xformers_memory_efficient_attention() + except Exception: + pass + + if opt == "sequential_cpu_offload": + # Each layer moved to GPU only during its forward pass — very VRAM-efficient + # enable_sequential_cpu_offload() also calls .to(device) internally + try: + pipe.enable_sequential_cpu_offload() + except Exception: + pipe.to("cpu") + + elif opt == "model_cpu_offload": + # Entire sub-models (text encoder, unet/transformer, VAE) moved between CPU/GPU + # Faster than sequential but needs ~3-4 GB free to hold the active module + try: + pipe.enable_model_cpu_offload() + except Exception: + pipe.to(device) + + elif opt == "attention_slicing": + try: + pipe.enable_attention_slicing(1) + except Exception: + pass + pipe.to(device) + + else: # "none" + pipe.to(device) + + return pipe + + +# ── Provider ───────────────────────────────────────────────────────────────── + +class LocalDiffusionProvider(RemoteAIProvider): + def __init__(self, max_cached_pipelines: int = 2): + self._cache = _PipelineCache(maxsize=max_cached_pipelines) + self._load_locks: dict[str, asyncio.Lock] = {} + self._meta_lock = asyncio.Lock() + + @property + def _info(self) -> GpuCapabilities: + return get_cached_gpu_info() + + async def _lock_for(self, key: str) -> asyncio.Lock: + async with self._meta_lock: + if key not in self._load_locks: + self._load_locks[key] = asyncio.Lock() + return self._load_locks[key] + + def _load_pipeline_sync(self, pipe_type: str) -> object: + info = self._info + spec = _get_spec(pipe_type, info) + + _apply_hf_token() + _set_state(pipe_type, pipeline=pipe_type, model_id=spec.model_id, + family=spec.family, memory_opt=spec.memory_opt, + state="downloading", progress=0.0, + message=f"Downloading {spec.model_id}…", error="") + try: + if spec.family == "flux": + pipe = _load_flux_pipeline(pipe_type, spec, info) + else: + pipe = _load_sd_pipeline(pipe_type, spec, info) + + _set_state(pipe_type, state="ready", progress=100.0, message="Ready") + return pipe + except Exception as exc: + _set_state(pipe_type, state="failed", error=str(exc), message="Load failed") + raise + + async def _get_pipeline(self, pipe_type: str) -> object: + cached = await self._cache.get(pipe_type) + if cached is not None: + return cached + + lock = await self._lock_for(pipe_type) + async with lock: + cached = await self._cache.get(pipe_type) + if cached is not None: + return cached + loop = asyncio.get_event_loop() + pipe = await loop.run_in_executor(None, self._load_pipeline_sync, pipe_type) + await self._cache.put(pipe_type, pipe) + return pipe + + # ── RemoteAIProvider ────────────────────────────────────────────────────── + + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: + pipe = await self._get_pipeline("inpaint") + spec = _get_spec("inpaint", self._info) + + img = Image.open(BytesIO(image_bytes)).convert("RGB") + mask = Image.open(BytesIO(mask_bytes)).convert("L") + orig = img.size + img_r, mask_r = _resize_pair(img, mask, spec.native_res) + + steps = int(params.get("steps", 30)) + cfg = float(params.get("cfg_scale", 7.5)) + neg = params.get("negative_prompt", "") or None + step_cb = _make_step_cb("inpaint", steps) + + _set_state("inpaint", state="running", step=0, total_steps=steps, progress=0, message="Starting…") + + def _run(): + try: + return pipe( + prompt=prompt, + negative_prompt=neg, + image=img_r, + mask_image=mask_r, + num_inference_steps=steps, + guidance_scale=cfg, + callback_on_step_end=step_cb, + callback_on_step_end_tensor_inputs=["latents"], + ).images[0].resize(orig, Image.LANCZOS) + except TypeError: + return pipe( + prompt=prompt, + negative_prompt=neg, + image=img_r, + mask_image=mask_r, + num_inference_steps=steps, + guidance_scale=cfg, + ).images[0].resize(orig, Image.LANCZOS) + + result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) + _set_state("inpaint", state="ready", step=None, total_steps=None, progress=100, message="Ready") + return result + + async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: + pipe = await self._get_pipeline("txt2img") + spec = _get_spec("txt2img", self._info) + + max_dim = spec.native_res + w = min(width, max_dim) // 8 * 8 + h = min(height, max_dim) // 8 * 8 + seed = int(params.get("seed", 0)) + is_flux = spec.family == "flux" + steps = 4 if is_flux else int(params.get("steps", 30)) + step_cb = _make_step_cb("txt2img", steps) + + _set_state("txt2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…") + + def _run(): + import torch + device = self._info.backend + gen = torch.Generator(device=device).manual_seed(seed) if seed else None + + try: + if is_flux: + return pipe( + prompt=prompt, + width=w, height=h, + num_inference_steps=steps, + guidance_scale=0.0, + max_sequence_length=256, + generator=gen, + callback_on_step_end=step_cb, + callback_on_step_end_tensor_inputs=["latents"], + ).images[0] + else: + return pipe( + prompt=prompt, + negative_prompt=params.get("negative_prompt", "") or None, + width=w, height=h, + num_inference_steps=steps, + guidance_scale=float(params.get("cfg_scale", 7.5)), + generator=gen, + callback_on_step_end=step_cb, + callback_on_step_end_tensor_inputs=["latents"], + ).images[0] + except TypeError: + # Older diffusers without callback_on_step_end + if is_flux: + return pipe( + prompt=prompt, width=w, height=h, + num_inference_steps=steps, guidance_scale=0.0, + max_sequence_length=256, generator=gen, + ).images[0] + else: + return pipe( + prompt=prompt, + negative_prompt=params.get("negative_prompt", "") or None, + width=w, height=h, + num_inference_steps=steps, + guidance_scale=float(params.get("cfg_scale", 7.5)), + generator=gen, + ).images[0] + + result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) + _set_state("txt2img", state="ready", step=None, total_steps=None, progress=100, message="Ready") + return result + + async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: + pipe = await self._get_pipeline("img2img") + spec = _get_spec("img2img", self._info) + + img = Image.open(BytesIO(image_bytes)).convert("RGB") + orig = img.size + img_r = _resize_square(img, spec.native_res) + is_flux = spec.family == "flux" + steps = 4 if is_flux else int(params.get("steps", 30)) + step_cb = _make_step_cb("img2img", steps) + + _set_state("img2img", state="running", step=0, total_steps=steps, progress=0, message="Starting…") + + def _run(): + try: + if is_flux: + result = pipe( + prompt=prompt, image=img_r, strength=strength, + num_inference_steps=steps, guidance_scale=0.0, + callback_on_step_end=step_cb, + callback_on_step_end_tensor_inputs=["latents"], + ).images[0] + else: + result = pipe( + prompt=prompt, + negative_prompt=params.get("negative_prompt", "") or None, + image=img_r, strength=strength, + num_inference_steps=steps, + guidance_scale=float(params.get("cfg_scale", 7.5)), + callback_on_step_end=step_cb, + callback_on_step_end_tensor_inputs=["latents"], + ).images[0] + except TypeError: + if is_flux: + result = pipe( + prompt=prompt, image=img_r, strength=strength, + num_inference_steps=steps, guidance_scale=0.0, + ).images[0] + else: + result = pipe( + prompt=prompt, + negative_prompt=params.get("negative_prompt", "") or None, + image=img_r, strength=strength, + num_inference_steps=steps, + guidance_scale=float(params.get("cfg_scale", 7.5)), + ).images[0] + return result.resize(orig, Image.LANCZOS) + + result = _to_png(await asyncio.get_event_loop().run_in_executor(None, _run)) + _set_state("img2img", state="ready", step=None, total_steps=None, progress=100, message="Ready") + return result + + async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: + from PIL import ImageDraw + + img = Image.open(BytesIO(image_bytes)).convert("RGB") + w, h = img.size + + positions = { + "right": ((w + size, h), (0, 0), (w, 0, w + size, h)), + "left": ((w + size, h), (size, 0), (0, 0, size, h)), + "bottom": ((w, h + size), (0, 0), (0, h, w, h + size)), + "top": ((w, h + size), (0, size), (0, 0, w, size)), + } + new_size, paste_at, mask_box = positions[direction] + + expanded = Image.new("RGB", new_size, (127, 127, 127)) + expanded.paste(img, paste_at) + mask = Image.new("L", new_size, 0) + ImageDraw.Draw(mask).rectangle(mask_box, fill=255) + + fill_prompt = prompt or "seamless natural continuation of the scene" + return await self.inpaint(_to_png(expanded), _to_png(mask), fill_prompt, {}) + + async def health(self) -> bool: + return True + + def capabilities(self) -> list[str]: + return self._info.capabilities + + +# ── Image utilities ─────────────────────────────────────────────────────────── + +def _resize_pair(img: Image.Image, mask: Image.Image, target: int): + w, h = img.size + scale = target / max(w, h) + nw = max(8, int(w * scale) // 8 * 8) + nh = max(8, int(h * scale) // 8 * 8) + return img.resize((nw, nh), Image.LANCZOS), mask.resize((nw, nh), Image.NEAREST) + + +def _resize_square(img: Image.Image, target: int) -> Image.Image: + w, h = img.size + scale = target / max(w, h) + nw = max(8, int(w * scale) // 8 * 8) + nh = max(8, int(h * scale) // 8 * 8) + return img.resize((nw, nh), Image.LANCZOS) + + +def _to_png(img: Image.Image) -> bytes: + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +# ── Singleton ───────────────────────────────────────────────────────────────── + +_provider: Optional[LocalDiffusionProvider] = None + + +def get_local_diffusion_provider(max_pipelines: int = 2) -> LocalDiffusionProvider: + global _provider + if _provider is None: + _provider = LocalDiffusionProvider(max_cached_pipelines=max_pipelines) + return _provider + + +async def prefetch_model_files() -> None: + """ + Download model weight files to HuggingFace disk cache without loading into GPU. + Called at container startup so the first request loads from disk (fast). + """ + from app.services.gpu_detect import get_cached_gpu_info + try: + from huggingface_hub import snapshot_download + except ImportError: + print("[local_gpu] huggingface_hub not installed — skipping model prefetch") + return + + info = get_cached_gpu_info() + _apply_hf_token() + + loop = asyncio.get_event_loop() + seen: set[str] = set() + + for op, spec in info.recommended.items(): + if spec is None or spec.model_id in seen: + continue + seen.add(spec.model_id) + + # Apply user override if set + try: + from app.config import settings + override_map = { + "inpaint": settings.hf_model_inpaint, + "txt2img": settings.hf_model_txt2img, + "img2img": settings.hf_model_img2img, + } + override = override_map.get(op, "") or "" + if override and override not in seen: + seen.add(override) + spec = infer_spec_from_model_id(override) + except Exception: + pass + + _set_state(op, pipeline=op, model_id=spec.model_id, family=spec.family, + memory_opt=spec.memory_opt, state="downloading", progress=0.0, + message=f"Downloading {spec.model_id}…", error="") + print(f"[local_gpu] Prefetching: {spec.model_id}") + + def _dl(model_id=spec.model_id): + snapshot_download( + repo_id=model_id, + ignore_patterns=["*.msgpack", "flax_*", "tf_*", "rust_model*"], + ) + + try: + await loop.run_in_executor(None, _dl) + _set_state(op, state="cached", progress=100.0, + message="Files cached — loads into GPU on first request") + print(f"[local_gpu] ✓ Cached: {spec.model_id}") + except Exception as exc: + _set_state(op, state="download_failed", error=str(exc), + message="Download failed — will retry on first request") + print(f"[local_gpu] Prefetch failed for {spec.model_id}: {exc}") diff --git a/paintplus/backend/app/services/local_inpaint.py b/paintplus/backend/app/services/local_inpaint.py new file mode 100644 index 0000000..cd82997 --- /dev/null +++ b/paintplus/backend/app/services/local_inpaint.py @@ -0,0 +1,82 @@ +""" +Local inpainting operations — LaMa, OpenCV, and background removal. +All operations use GPU automatically if PyTorch detects one, CPU otherwise. +""" + +from io import BytesIO +from PIL import Image +import numpy as np +import cv2 + +# Lazy-loaded LaMa model (downloaded on first use, ~100MB) +_lama = None + + +def get_lama(): + global _lama + if _lama is None: + from simple_lama_inpainting import SimpleLama + _lama = SimpleLama() + return _lama + + +def lama_available() -> bool: + try: + import simple_lama_inpainting # noqa: F401 + return True + except ImportError: + return False + + +def lama_inpaint(image_bytes: bytes, mask_bytes: bytes) -> bytes: + """LaMa structural inpainting — best for object removal and large fills.""" + lama = get_lama() + image = Image.open(BytesIO(image_bytes)).convert("RGB") + mask = Image.open(BytesIO(mask_bytes)).convert("L") + if mask.size != image.size: + mask = mask.resize(image.size, Image.Resampling.LANCZOS) + result = lama(image, mask) + buf = BytesIO() + result.save(buf, format="PNG") + return buf.getvalue() + + +def opencv_inpaint(image_bytes: bytes, mask_bytes: bytes, method: str = "telea") -> bytes: + """OpenCV fast structural inpainting — CPU only, milliseconds.""" + image = Image.open(BytesIO(image_bytes)).convert("RGB") + mask = Image.open(BytesIO(mask_bytes)).convert("L") + if mask.size != image.size: + mask = mask.resize(image.size, Image.Resampling.LANCZOS) + + img_np = np.array(image) + mask_np = np.array(mask) + _, mask_bin = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY) + + flags = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS + result = cv2.inpaint(img_np, mask_bin, inpaintRadius=3, flags=flags) + + buf = BytesIO() + Image.fromarray(result).save(buf, format="PNG") + return buf.getvalue() + + +def remove_background_rembg(image_bytes: bytes) -> bytes: + """Background removal using rembg.""" + from rembg import remove + return remove(image_bytes) + + +def rembg_available() -> bool: + try: + import rembg # noqa: F401 + return True + except ImportError: + return False + + +def gpu_available() -> bool: + try: + import torch + return torch.cuda.is_available() + except ImportError: + return False diff --git a/paintplus/backend/app/services/patch_library.py b/paintplus/backend/app/services/patch_library.py new file mode 100644 index 0000000..a1989b6 --- /dev/null +++ b/paintplus/backend/app/services/patch_library.py @@ -0,0 +1,206 @@ +import os +import shutil +from pathlib import Path +from typing import List, Optional +from PIL import Image +from datetime import datetime + +from app.models.patch import Patch +from app.config import settings + + +class PatchLibraryService: + """Service for managing the patch library""" + + def __init__(self, data_dir: str = None): + self.data_dir = data_dir or settings.data_dir + self.patch_library_dir = Path(self.data_dir) / "patch_library" + self.patch_library_dir.mkdir(parents=True, exist_ok=True) + + def get_patch_path(self, patch_id: int) -> Path: + """Get path to patch file""" + return self.patch_library_dir / f"{patch_id}.png" + + def get_thumbnail_path(self, patch_id: int) -> Path: + """Get path to patch thumbnail""" + return self.patch_library_dir / f"{patch_id}_thumb.png" + + def create_thumbnail(self, image_path: Path, thumbnail_path: Path, size: tuple = (200, 200)): + """Create a thumbnail from an image""" + img = Image.open(image_path) + img.thumbnail(size, Image.Resampling.LANCZOS) + img.save(thumbnail_path, 'PNG') + + def save_patch_from_file( + self, + patch_id: int, + image_path: str, + create_thumb: bool = True + ) -> str: + """ + Save a patch from an existing file + + Args: + patch_id: Patch ID + image_path: Source image path + create_thumb: Whether to create thumbnail + + Returns: + Relative path to saved patch + """ + patch_path = self.get_patch_path(patch_id) + shutil.copy(image_path, patch_path) + + if create_thumb: + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def save_patch_from_bytes( + self, + patch_id: int, + image_bytes: bytes, + create_thumb: bool = True + ) -> str: + """ + Save a patch from bytes + + Args: + patch_id: Patch ID + image_bytes: Image data as bytes + create_thumb: Whether to create thumbnail + + Returns: + Relative path to saved patch + """ + patch_path = self.get_patch_path(patch_id) + + # Save image + with open(patch_path, 'wb') as f: + f.write(image_bytes) + + if create_thumb: + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def save_ai_generated_patch( + self, + patch_id: int, + edit_dir: Path + ) -> str: + """ + Save an AI-generated patch from an edit + + Args: + patch_id: Patch ID + edit_dir: Path to edit history directory + + Returns: + Relative path to saved patch + """ + # Use the AI-generated output (patch_out.png) + source_path = edit_dir / "patch_out.png" + return self.save_patch_from_file(patch_id, str(source_path)) + + def save_manual_patch( + self, + patch_id: int, + project_id: int, + bbox: dict + ) -> str: + """ + Save a manually selected patch from current project image + + Args: + patch_id: Patch ID + project_id: Project ID + bbox: Bounding box {x, y, width, height} + + Returns: + Relative path to saved patch + """ + from app.services.edit_service import EditService + from app.utils.image_processing import crop_patch + + edit_service = EditService(self.data_dir) + current_image_path = edit_service.get_current_image_path(project_id) + + # Load and crop current image + img = Image.open(current_image_path) + patch = crop_patch(img, bbox) + + # Save patch + patch_path = self.get_patch_path(patch_id) + patch.save(patch_path, 'PNG') + + # Create thumbnail + thumbnail_path = self.get_thumbnail_path(patch_id) + self.create_thumbnail(patch_path, thumbnail_path) + + return str(patch_path.relative_to(self.data_dir)) + + def apply_patch_to_image( + self, + patch_id: int, + target_image: Image.Image, + bbox: dict, + feather_px: int = 5 + ) -> Image.Image: + """ + Apply a saved patch to a target image + + Args: + patch_id: Patch ID to apply + target_image: Target image to apply patch to + bbox: Where to place the patch {x, y, width, height} + feather_px: Feather radius for blending + + Returns: + Image with patch applied + """ + from app.utils.image_processing import insert_patch, create_feathered_mask + from PIL import ImageOps + + # Load patch + patch_path = self.get_patch_path(patch_id) + patch = Image.open(patch_path).convert('RGBA') + + # Resize patch to match bbox if needed + if patch.size != (bbox['width'], bbox['height']): + patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS) + + # Create a soft-edged mask for the patch + mask = Image.new('L', patch.size, 255) + if feather_px > 0: + mask = create_feathered_mask(mask, feather_px) + + # Apply mask to patch + patch.putalpha(mask) + + # Insert patch into target image + result = insert_patch(target_image, patch, bbox) + + return result + + def delete_patch(self, patch_id: int): + """Delete a patch and its thumbnail""" + patch_path = self.get_patch_path(patch_id) + thumbnail_path = self.get_thumbnail_path(patch_id) + + if patch_path.exists(): + patch_path.unlink() + + if thumbnail_path.exists(): + thumbnail_path.unlink() + + def get_patch_size(self, patch_id: int) -> tuple: + """Get patch dimensions""" + patch_path = self.get_patch_path(patch_id) + if not patch_path.exists(): + return (0, 0) + + img = Image.open(patch_path) + return img.size diff --git a/paintplus/backend/app/services/remote_provider.py b/paintplus/backend/app/services/remote_provider.py new file mode 100644 index 0000000..5fcb603 --- /dev/null +++ b/paintplus/backend/app/services/remote_provider.py @@ -0,0 +1,474 @@ +""" +Remote AI provider abstraction. +One interface, three drivers: OpenAI, InvokeAI, ComfyUI. +Configure one provider via AI_PROVIDER in .env. +""" + +from abc import ABC, abstractmethod +from typing import Optional +import httpx +import base64 +import asyncio +from io import BytesIO + + +class RemoteAIProvider(ABC): + @abstractmethod + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: ... + @abstractmethod + async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: ... + @abstractmethod + async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: ... + @abstractmethod + async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: ... + @abstractmethod + async def health(self) -> bool: ... + @abstractmethod + def capabilities(self) -> list[str]: ... + + +class OpenAIRemoteProvider(RemoteAIProvider): + """OpenAI image API — gpt-image-1 / dall-e-3.""" + + def __init__(self, api_key: str, model: str = "dall-e-3"): + self.api_key = api_key + self.model = model + self.base_url = "https://api.openai.com/v1" + + def _headers(self): + return {"Authorization": f"Bearer {self.api_key}"} + + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: + async with httpx.AsyncClient(timeout=120.0) as client: + files = { + "image": ("image.png", image_bytes, "image/png"), + "mask": ("mask.png", mask_bytes, "image/png"), + } + data = {"prompt": prompt, "n": "1", "size": "1024x1024"} + r = await client.post(f"{self.base_url}/images/edits", files=files, data=data, headers=self._headers()) + r.raise_for_status() + url = r.json()["data"][0]["url"] + img_r = await client.get(url) + img_r.raise_for_status() + return img_r.content + + async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: + size = f"{width}x{height}" if f"{width}x{height}" in {"256x256", "512x512", "1024x1024"} else "1024x1024" + async with httpx.AsyncClient(timeout=120.0) as client: + data = {"model": self.model, "prompt": prompt, "n": 1, "size": size} + r = await client.post(f"{self.base_url}/images/generations", json=data, headers=self._headers()) + r.raise_for_status() + url = r.json()["data"][0]["url"] + img_r = await client.get(url) + img_r.raise_for_status() + return img_r.content + + async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: + # OpenAI doesn't have img2img natively — use edits with blank mask + from PIL import Image + import numpy as np + img = Image.open(BytesIO(image_bytes)).convert("RGBA") + mask = Image.new("RGBA", img.size, (0, 0, 0, 0)) + mask_buf = BytesIO() + mask.save(mask_buf, format="PNG") + return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, params) + + async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: + from PIL import Image + img = Image.open(BytesIO(image_bytes)).convert("RGBA") + w, h = img.size + directions = {"left": (size, 0), "right": (size, 0), "top": (0, size), "bottom": (0, size)} + dw, dh = directions.get(direction, (size, 0)) + new_w, new_h = w + dw, h + dh + canvas = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0)) + offsets = { + "left": (size, 0), "right": (0, 0), "top": (0, size), "bottom": (0, 0) + } + ox, oy = offsets.get(direction, (0, 0)) + canvas.paste(img, (ox, oy)) + # mask: transparent = inpaint + mask = Image.new("L", (new_w, new_h), 0) + # fill the expanded region with white in mask + import numpy as np + mask_arr = np.zeros((new_h, new_w), dtype=np.uint8) + if direction == "left": + mask_arr[:, :size] = 255 + elif direction == "right": + mask_arr[:, w:] = 255 + elif direction == "top": + mask_arr[:size, :] = 255 + else: + mask_arr[h:, :] = 255 + mask = Image.fromarray(mask_arr, "L") + + canvas_rgb = canvas.convert("RGB") + img_buf = BytesIO() + canvas_rgb.save(img_buf, format="PNG") + mask_buf = BytesIO() + mask.save(mask_buf, format="PNG") + return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {}) + + async def health(self) -> bool: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(f"{self.base_url}/models", headers=self._headers()) + return r.status_code == 200 + except Exception: + return False + + def capabilities(self) -> list[str]: + return ["inpaint", "txt2img", "img2img", "outpaint"] + + +class InvokeAIProvider(RemoteAIProvider): + """InvokeAI REST API driver — supports Flux, SDXL, SD1.5 and more.""" + + def __init__(self, base_url: str, default_model: str = "flux-dev"): + self.base_url = base_url.rstrip("/") + self.default_model = default_model + + async def _b64(self, data: bytes) -> str: + return base64.b64encode(data).decode() + + async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, category: str = "general") -> str: + """Upload image to InvokeAI and return image_name.""" + files = {"file": ("image.png", image_bytes, "image/png")} + data = {"image_category": category, "is_intermediate": "false"} + r = await client.post(f"{self.base_url}/api/v1/images/upload", files=files, data=data) + r.raise_for_status() + return r.json()["image_name"] + + async def _run_graph(self, client: httpx.AsyncClient, graph: dict) -> bytes: + """Post a graph, poll for completion, return result image bytes.""" + r = await client.post(f"{self.base_url}/api/v1/queue/default/enqueue_batch", + json={"prepend": False, "batch": {"graph": graph, "runs": 1}}) + r.raise_for_status() + batch_id = r.json()["batch"]["batch_id"] + + # Poll queue status + for _ in range(180): + await asyncio.sleep(2) + sr = await client.get(f"{self.base_url}/api/v1/queue/default/status") + sr.raise_for_status() + status = sr.json() + if status.get("queue", {}).get("completed", 0) > 0: + break + if status.get("queue", {}).get("failed", 0) > 0: + raise RuntimeError("InvokeAI graph failed") + + # Fetch latest result image + lr = await client.get(f"{self.base_url}/api/v1/images/?categories=general&limit=1&is_intermediate=false") + lr.raise_for_status() + items = lr.json().get("items", []) + if not items: + raise RuntimeError("No output image from InvokeAI") + + img_name = items[0]["image_name"] + img_r = await client.get(f"{self.base_url}/api/v1/images/i/{img_name}/full") + img_r.raise_for_status() + return img_r.content + + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: + async with httpx.AsyncClient(timeout=300.0) as client: + img_name = await self._upload_image(client, image_bytes) + mask_name = await self._upload_image(client, mask_bytes, "mask") + model = params.get("model", self.default_model) + graph = { + "id": "inpaint_graph", + "nodes": { + "img_node": {"id": "img_node", "type": "image", "image": {"image_name": img_name}}, + "mask_node": {"id": "mask_node", "type": "image", "image": {"image_name": mask_name}}, + "model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}}, + "clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0}, + "positive": {"id": "positive", "type": "compel", "prompt": prompt}, + "negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")}, + "denoise": { + "id": "denoise", "type": "denoise_latents", + "steps": params.get("steps", 30), + "cfg_scale": params.get("cfg_scale", 7.5), + "denoising_start": 0.0, "denoising_end": 1.0, + "scheduler": "euler", "is_intermediate": False + }, + "vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}}, + "img_to_latents": {"id": "img_to_latents", "type": "i2l"}, + "latents_to_img": {"id": "latents_to_img", "type": "l2i"}, + }, + "edges": [ + {"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}}, + {"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}}, + {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}}, + {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}}, + {"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}}, + {"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}}, + {"source": {"node_id": "img_node", "field": "image"}, "destination": {"node_id": "img_to_latents", "field": "image"}}, + {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "img_to_latents", "field": "vae"}}, + {"source": {"node_id": "img_to_latents", "field": "latents"}, "destination": {"node_id": "denoise", "field": "latents"}}, + {"source": {"node_id": "mask_node", "field": "image"}, "destination": {"node_id": "denoise", "field": "mask"}}, + {"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}}, + {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}}, + ] + } + return await self._run_graph(client, graph) + + async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: + async with httpx.AsyncClient(timeout=300.0) as client: + model = params.get("model", self.default_model) + graph = { + "id": "txt2img_graph", + "nodes": { + "model_node": {"id": "model_node", "type": "main_model_loader", "model": {"model_name": model, "base": "any"}}, + "clip_skip": {"id": "clip_skip", "type": "clip_skip", "skipped_layers": 0}, + "positive": {"id": "positive", "type": "compel", "prompt": prompt}, + "negative": {"id": "negative", "type": "compel", "prompt": params.get("negative_prompt", "")}, + "noise": {"id": "noise", "type": "noise", "width": width, "height": height, "seed": params.get("seed", 0)}, + "denoise": { + "id": "denoise", "type": "denoise_latents", + "steps": params.get("steps", 30), + "cfg_scale": params.get("cfg_scale", 7.5), + "denoising_start": 0.0, "denoising_end": 1.0, + "scheduler": "euler", + }, + "vae_loader": {"id": "vae_loader", "type": "vae_loader", "vae_model": {"model_name": model, "base": "any"}}, + "latents_to_img": {"id": "latents_to_img", "type": "l2i"}, + }, + "edges": [ + {"source": {"node_id": "model_node", "field": "unet"}, "destination": {"node_id": "denoise", "field": "unet"}}, + {"source": {"node_id": "model_node", "field": "clip"}, "destination": {"node_id": "clip_skip", "field": "clip"}}, + {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "positive", "field": "clip"}}, + {"source": {"node_id": "clip_skip", "field": "clip"}, "destination": {"node_id": "negative", "field": "clip"}}, + {"source": {"node_id": "positive", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "positive_conditioning"}}, + {"source": {"node_id": "negative", "field": "conditioning"}, "destination": {"node_id": "denoise", "field": "negative_conditioning"}}, + {"source": {"node_id": "noise", "field": "noise"}, "destination": {"node_id": "denoise", "field": "noise"}}, + {"source": {"node_id": "denoise", "field": "latents"}, "destination": {"node_id": "latents_to_img", "field": "latents"}}, + {"source": {"node_id": "vae_loader", "field": "vae"}, "destination": {"node_id": "latents_to_img", "field": "vae"}}, + ] + } + return await self._run_graph(client, graph) + + async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: + # Reuse inpaint with a full-white mask at the given strength + from PIL import Image + img = Image.open(BytesIO(image_bytes)) + mask = Image.new("L", img.size, 255) + mask_buf = BytesIO() + mask.save(mask_buf, format="PNG") + p = dict(params) + p.setdefault("denoising_start", 1.0 - strength) + return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p) + + async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: + # Delegate to inpaint with expanded canvas + provider = OpenAIRemoteProvider.__new__(OpenAIRemoteProvider) + return await provider.outpaint(image_bytes, direction, size, prompt) + + async def health(self) -> bool: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(f"{self.base_url}/api/v1/app/version") + return r.status_code == 200 + except Exception: + return False + + def capabilities(self) -> list[str]: + return ["inpaint", "txt2img", "img2img", "outpaint"] + + +class ComfyUIProvider(RemoteAIProvider): + """ComfyUI workflow JSON API driver.""" + + def __init__(self, base_url: str, default_model: str = "v1-5-pruned-emaonly.ckpt"): + self.base_url = base_url.rstrip("/") + self.default_model = default_model + + async def _upload_image(self, client: httpx.AsyncClient, image_bytes: bytes, name: str = "image.png") -> str: + files = {"image": (name, image_bytes, "image/png")} + data = {"overwrite": "true"} + r = await client.post(f"{self.base_url}/upload/image", files=files, data=data) + r.raise_for_status() + j = r.json() + return j.get("name", name) + + async def _queue_prompt(self, client: httpx.AsyncClient, workflow: dict) -> str: + r = await client.post(f"{self.base_url}/prompt", json={"prompt": workflow}) + r.raise_for_status() + return r.json()["prompt_id"] + + async def _wait_for_result(self, client: httpx.AsyncClient, prompt_id: str) -> bytes: + for _ in range(180): + await asyncio.sleep(2) + r = await client.get(f"{self.base_url}/history/{prompt_id}") + r.raise_for_status() + history = r.json() + if prompt_id in history: + outputs = history[prompt_id].get("outputs", {}) + for node_output in outputs.values(): + for img_info in node_output.get("images", []): + img_r = await client.get( + f"{self.base_url}/view", + params={"filename": img_info["filename"], "subfolder": img_info.get("subfolder", ""), + "type": img_info.get("type", "output")} + ) + img_r.raise_for_status() + return img_r.content + raise RuntimeError("ComfyUI timed out waiting for result") + + async def inpaint(self, image_bytes: bytes, mask_bytes: bytes, prompt: str, params: dict) -> bytes: + model = params.get("model", self.default_model) + async with httpx.AsyncClient(timeout=300.0) as client: + img_name = await self._upload_image(client, image_bytes, "input.png") + mask_name = await self._upload_image(client, mask_bytes, "mask.png") + workflow = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}}, + "3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}}, + "4": {"class_type": "LoadImage", "inputs": {"image": img_name}}, + "5": {"class_type": "LoadImage", "inputs": {"image": mask_name}}, + "6": {"class_type": "VAEEncode", "inputs": {"pixels": ["4", 0], "vae": ["1", 2]}}, + "7": {"class_type": "KSampler", "inputs": { + "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], + "latent_image": ["6", 0], "mask": ["5", 0], + "seed": params.get("seed", 42), "steps": params.get("steps", 20), + "cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler", + "scheduler": "normal", "denoise": params.get("denoise", 1.0) + }}, + "8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["1", 2]}}, + "9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0], "filename_prefix": "api_out"}}, + } + pid = await self._queue_prompt(client, workflow) + return await self._wait_for_result(client, pid) + + async def txt2img(self, prompt: str, width: int, height: int, params: dict) -> bytes: + model = params.get("model", self.default_model) + async with httpx.AsyncClient(timeout=300.0) as client: + workflow = { + "1": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": model}}, + "2": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["1", 1]}}, + "3": {"class_type": "CLIPTextEncode", "inputs": {"text": params.get("negative_prompt", ""), "clip": ["1", 1]}}, + "4": {"class_type": "EmptyLatentImage", "inputs": {"width": width, "height": height, "batch_size": 1}}, + "5": {"class_type": "KSampler", "inputs": { + "model": ["1", 0], "positive": ["2", 0], "negative": ["3", 0], + "latent_image": ["4", 0], + "seed": params.get("seed", 42), "steps": params.get("steps", 20), + "cfg": params.get("cfg_scale", 7.0), "sampler_name": "euler", + "scheduler": "normal", "denoise": 1.0 + }}, + "6": {"class_type": "VAEDecode", "inputs": {"samples": ["5", 0], "vae": ["1", 2]}}, + "7": {"class_type": "SaveImage", "inputs": {"images": ["6", 0], "filename_prefix": "api_out"}}, + } + pid = await self._queue_prompt(client, workflow) + return await self._wait_for_result(client, pid) + + async def img2img(self, image_bytes: bytes, prompt: str, strength: float, params: dict) -> bytes: + from PIL import Image + img = Image.open(BytesIO(image_bytes)) + mask = Image.new("L", img.size, 255) + mask_buf = BytesIO() + mask.save(mask_buf, format="PNG") + p = dict(params) + p["denoise"] = strength + return await self.inpaint(image_bytes, mask_buf.getvalue(), prompt, p) + + async def outpaint(self, image_bytes: bytes, direction: str, size: int, prompt: str) -> bytes: + # Build expanded canvas then inpaint with blank mask + from PIL import Image + import numpy as np + img = Image.open(BytesIO(image_bytes)).convert("RGB") + w, h = img.size + dw = size if direction in ("left", "right") else 0 + dh = size if direction in ("top", "bottom") else 0 + canvas = Image.new("RGB", (w + dw, h + dh), (128, 128, 128)) + ox = size if direction == "left" else 0 + oy = size if direction == "top" else 0 + canvas.paste(img, (ox, oy)) + mask_arr = np.zeros((h + dh, w + dw), dtype=np.uint8) + if direction == "left": + mask_arr[:, :size] = 255 + elif direction == "right": + mask_arr[:, w:] = 255 + elif direction == "top": + mask_arr[:size, :] = 255 + else: + mask_arr[h:, :] = 255 + img_buf = BytesIO() + canvas.save(img_buf, format="PNG") + mask_buf = BytesIO() + Image.fromarray(mask_arr, "L").save(mask_buf, format="PNG") + return await self.inpaint(img_buf.getvalue(), mask_buf.getvalue(), prompt, {}) + + async def health(self) -> bool: + try: + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get(f"{self.base_url}/system_stats") + return r.status_code == 200 + except Exception: + return False + + def capabilities(self) -> list[str]: + return ["inpaint", "txt2img", "img2img", "outpaint"] + + +def _build_provider(name: str) -> Optional[RemoteAIProvider]: + """Instantiate a named provider from current settings.""" + from app.config import settings + + name = (name or "").lower().strip() + + if name == "openai": + if not settings.openai_api_key: + return None + return OpenAIRemoteProvider(settings.openai_api_key, settings.openai_model) + + if name == "invokeai": + if not settings.invokeai_url: + return None + return InvokeAIProvider(settings.invokeai_url, settings.invokeai_default_model) + + if name == "comfyui": + if not settings.comfyui_url: + return None + return ComfyUIProvider(settings.comfyui_url, settings.comfyui_default_model) + + if name == "local_gpu": + try: + from app.services.local_diffusion import get_local_diffusion_provider + return get_local_diffusion_provider(max_pipelines=settings.local_gpu_max_pipelines) + except (ImportError, AttributeError) as exc: + print(f"[local_gpu] Cannot load diffusion provider: {exc}") + return None + + return None + + +# Map operation names to the settings field that holds the override +_OP_FIELD = { + "inpaint": "ai_provider_inpaint", + "txt2img": "ai_provider_txt2img", + "img2img": "ai_provider_img2img", + "outpaint": "ai_provider_outpaint", +} + + +def get_remote_provider(operation: Optional[str] = None) -> Optional[RemoteAIProvider]: + """ + Return the provider for a given operation. + + Resolution order: + 1. Per-operation override (AI_PROVIDER_INPAINT, AI_PROVIDER_TXT2IMG, etc.) + 2. Global default (AI_PROVIDER) + 3. None (local-only mode) + + Example .env for mixed setup: + AI_PROVIDER=invokeai # default for inpaint/img2img/outpaint + AI_PROVIDER_TXT2IMG=openai # use OpenAI only for text-to-image + """ + from app.config import settings + + if operation and operation in _OP_FIELD: + override = getattr(settings, _OP_FIELD[operation], "") + if override: + provider = _build_provider(override) + if provider is not None: + return provider + # override configured but not usable (missing key/url) — fall through to default + + return _build_provider(settings.ai_provider) diff --git a/paintplus/backend/app/services/sam_service.py b/paintplus/backend/app/services/sam_service.py new file mode 100644 index 0000000..b10e7ea --- /dev/null +++ b/paintplus/backend/app/services/sam_service.py @@ -0,0 +1,184 @@ +""" +SAM (Segment Anything Model) service. + +Auto-downloads the ViT-B checkpoint (~375 MB) on first use. +Caches the loaded model in memory; re-uses predictor across calls. + +Prediction API: + predict_points(image_bytes, points, labels) -> mask_bytes (PNG, white=selected) + points: list of (x, y) in original image pixels + labels: list of 1 (include) or 0 (exclude), same length as points +""" + +import asyncio +import io +import os +import urllib.request +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Optional + +import numpy as np +from PIL import Image + +# ── Model download ──────────────────────────────────────────────────────────── + +SAM_DIR = Path("/app/data/models/sam") +SAM_FILENAME = "sam_vit_b_01ec64.pth" +SAM_URL = f"https://dl.fbaipublicfiles.com/segment_anything/{SAM_FILENAME}" +SAM_PATH = SAM_DIR / SAM_FILENAME + + +class SamInstallState(str, Enum): + idle = "idle" + downloading = "downloading" + done = "done" + failed = "failed" + + +@dataclass +class SamInstallStatus: + state: SamInstallState = SamInstallState.idle + progress: int = 0 + message: str = "" + error: str = "" + + +_install_status = SamInstallStatus() +_install_lock = asyncio.Lock() + + +def get_install_status() -> dict: + s = _install_status + return {"state": s.state.value, "progress": s.progress, + "message": s.message, "error": s.error} + + +def sam_model_available() -> bool: + return SAM_PATH.exists() and SAM_PATH.stat().st_size > 100_000_000 + + +async def ensure_sam_installed() -> bool: + """Download SAM ViT-B checkpoint if not present. Returns True on success.""" + global _install_status + + if sam_model_available(): + _install_status = SamInstallStatus(state=SamInstallState.done, progress=100, + message="SAM model ready.") + return True + + async with _install_lock: + if sam_model_available(): + _install_status = SamInstallStatus(state=SamInstallState.done, progress=100, + message="SAM model ready.") + return True + + if _install_status.state == SamInstallState.downloading: + return False + + try: + SAM_DIR.mkdir(parents=True, exist_ok=True) + _install_status = SamInstallStatus( + state=SamInstallState.downloading, progress=0, + message="Downloading SAM ViT-B model (~375 MB)…", + ) + + def _download(): + def _progress(count, block, total): + if total > 0: + _install_status.progress = min(99, int(count * block * 99 / total)) + tmp = SAM_PATH.with_suffix(".tmp") + urllib.request.urlretrieve(SAM_URL, tmp, _progress) + tmp.rename(SAM_PATH) + + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _download) + + _install_status = SamInstallStatus(state=SamInstallState.done, progress=100, + message="SAM model ready.") + return True + + except Exception as exc: + _install_status = SamInstallStatus( + state=SamInstallState.failed, error=str(exc), + message="SAM download failed.", + ) + print(f"[sam] Download failed: {exc}") + return False + + +# ── Model cache ─────────────────────────────────────────────────────────────── + +_predictor = None +_predictor_lock = asyncio.Lock() + + +def _load_predictor(): + """Load SAM model and return a SamPredictor. Called in thread pool.""" + global _predictor + if _predictor is not None: + return _predictor + + import torch + from segment_anything import sam_model_registry, SamPredictor + + if torch.cuda.is_available(): + device = "cuda" + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + device = "mps" + else: + device = "cpu" + + print(f"[sam] Loading SAM ViT-B on {device}…") + sam = sam_model_registry["vit_b"](checkpoint=str(SAM_PATH)) + sam.to(device) + _predictor = SamPredictor(sam) + print("[sam] Model loaded.") + return _predictor + + +# ── Prediction ──────────────────────────────────────────────────────────────── + +def _predict_sync(image_bytes: bytes, + points: list[tuple[int, int]], + labels: list[int]) -> bytes: + """ + Run SAM prediction synchronously (call via run_in_executor). + Returns PNG bytes: white = selected, black = background. + """ + predictor = _load_predictor() + + image = Image.open(io.BytesIO(image_bytes)).convert("RGB") + img_array = np.array(image) + + predictor.set_image(img_array) + + pt_array = np.array(points, dtype=np.float32) # [[x, y], ...] + lbl_array = np.array(labels, dtype=np.int32) # [1=fg, 0=bg, ...] + + masks, scores, _ = predictor.predict( + point_coords=pt_array, + point_labels=lbl_array, + multimask_output=True, + ) + + # Pick the highest-confidence mask + best = masks[int(np.argmax(scores))] # bool array H×W + + mask_img = Image.fromarray((best * 255).astype(np.uint8), mode="L") + buf = io.BytesIO() + mask_img.save(buf, format="PNG") + return buf.getvalue() + + +async def predict_points(image_bytes: bytes, + points: list[tuple[int, int]], + labels: list[int]) -> bytes: + """Async wrapper for SAM point prediction.""" + if not sam_model_available(): + ok = await ensure_sam_installed() + if not ok: + raise RuntimeError("SAM model not available.") + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, _predict_sync, image_bytes, points, labels) diff --git a/paintplus/backend/app/services/upscale.py b/paintplus/backend/app/services/upscale.py new file mode 100644 index 0000000..62db22c --- /dev/null +++ b/paintplus/backend/app/services/upscale.py @@ -0,0 +1,494 @@ +""" +Upscale service — auto-detects best available method and runs it. +Auto-installs Real-ESRGAN NCNN Vulkan binary when Vulkan GPU is available. +Skips NCNN on headless/CPU-only machines and uses PyTorch CPU or Lanczos instead. + +Priority (auto mode): + 1. Real-ESRGAN PyTorch + CUDA GPU — fastest, best quality + 2. Real-ESRGAN PyTorch + Apple MPS — fast on Apple Silicon + 3. Real-ESRGAN NCNN Vulkan binary — fast on any Vulkan GPU + 4. Real-ESRGAN PyTorch CPU — AI quality, slow (~1-3 min) + 5. Lanczos — always available, instant + +Capability probe is run once at first call and cached. +NCNN binary is auto-downloaded only when Vulkan is detected. +Set REALESRGAN_NCNN=force env var to override the Vulkan check. +""" + +import asyncio +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import urllib.request +import zipfile +from dataclasses import dataclass +from enum import Enum +from io import BytesIO +from pathlib import Path +from typing import Optional + +from PIL import Image + +# ── NCNN auto-install ───────────────────────────────────────────────────────── + +NCNN_DEST_DIR = Path("/app/data/models/realesrgan") +NCNN_VERSION = "v0.2.5.0" +NCNN_BASE_URL = f"https://github.com/xinntao/Real-ESRGAN/releases/download/{NCNN_VERSION}" + +_PLATFORM_ZIP = { + "linux": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-ubuntu.zip", + "darwin": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-macos.zip", + "win32": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip", + "windows": f"realesrgan-ncnn-vulkan-{NCNN_VERSION}-windows.zip", +} + + +class InstallState(str, Enum): + idle = "idle" + skipped = "skipped" # headless / no Vulkan + downloading = "downloading" + extracting = "extracting" + verifying = "verifying" + done = "done" + failed = "failed" + + +@dataclass +class InstallStatus: + state: InstallState = InstallState.idle + progress: int = 0 # 0-100 + message: str = "" + error: str = "" + + +_install_status = InstallStatus() +_install_lock = asyncio.Lock() + + +def get_install_status() -> dict: + s = _install_status + return { + "state": s.state.value, + "progress": s.progress, + "message": s.message, + "error": s.error, + } + + +def _ncnn_binary_name() -> str: + return "realesrgan-ncnn-vulkan.exe" if "win" in sys.platform.lower() else "realesrgan-ncnn-vulkan" + + +def _vulkan_available() -> bool: + """ + Check whether a Vulkan-capable GPU is accessible. + Returns True if confident a GPU with Vulkan exists; False on headless/CPU-only. + Set REALESRGAN_NCNN=force to bypass this check. + """ + if os.environ.get("REALESRGAN_NCNN", "").lower() == "force": + return True + + plat = sys.platform.lower() + + if plat == "linux": + # DRI render nodes exist when a GPU is present and drivers loaded + dri = Path("/dev/dri") + if dri.exists() and list(dri.glob("renderD*")): + return True + # Fallback: vulkaninfo (not always installed) + if shutil.which("vulkaninfo"): + r = subprocess.run(["vulkaninfo", "--summary"], + capture_output=True, timeout=5) + if r.returncode == 0 and b"GPU" in r.stdout: + return True + return False + + if plat == "darwin": + # macOS with Metal/MPS — Vulkan via MoltenVK always present on Apple Silicon/modern Intel + return True + + if "win" in plat: + # Windows always has a display adapter; assume Vulkan available + return True + + return False + + +def _test_ncnn_binary(binary_path: Path) -> bool: + """Run binary with --help to confirm it actually works (Vulkan loads ok).""" + try: + r = subprocess.run( + [str(binary_path), "--help"], + capture_output=True, timeout=15, + ) + # NCNN binary exits 255 for --help but prints usage; that's fine. + # A Vulkan init failure produces "no vulkan device" on stderr. + stderr = r.stderr.decode(errors="replace").lower() + if "no vulkan" in stderr or "failed to create" in stderr: + return False + return True + except Exception: + return False + + +async def ensure_ncnn_installed() -> Optional[Path]: + """ + Check for Vulkan, then download+install the NCNN binary if needed. + Skips silently on headless/CPU-only machines. + Returns binary Path on success, None otherwise. + """ + global _install_status + + binary_path = NCNN_DEST_DIR / _ncnn_binary_name() + + # Already installed — quick verify it still works + if binary_path.exists() and os.access(binary_path, os.X_OK): + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _test_ncnn_binary, binary_path) + if ok: + _install_status = InstallStatus(state=InstallState.done, progress=100, + message="Already installed.") + return binary_path + else: + # Binary exists but Vulkan broken — treat as headless + _install_status = InstallStatus( + state=InstallState.skipped, + message="Vulkan unavailable — skipping NCNN (using PyTorch CPU or Lanczos).", + ) + return None + + async with _install_lock: + # Re-check after lock + if binary_path.exists() and os.access(binary_path, os.X_OK): + _install_status = InstallStatus(state=InstallState.done, progress=100, + message="Already installed.") + return binary_path + + if _install_status.state in (InstallState.downloading, InstallState.extracting, + InstallState.verifying): + return None # already running + + # Check Vulkan before downloading anything + loop = asyncio.get_event_loop() + has_vulkan = await loop.run_in_executor(None, _vulkan_available) + if not has_vulkan: + _install_status = InstallStatus( + state=InstallState.skipped, + message="No Vulkan GPU detected — skipping NCNN install. " + "AI upscaling via PyTorch CPU or set REALESRGAN_NCNN=force to override.", + ) + print("[upscale] Headless/no-Vulkan detected — skipping NCNN download.") + return None + + plat = sys.platform.lower() + zip_name = _PLATFORM_ZIP.get(plat) + if not zip_name: + _install_status = InstallStatus( + state=InstallState.failed, + error=f"Unsupported platform: {plat}", + ) + return None + + url = f"{NCNN_BASE_URL}/{zip_name}" + + try: + NCNN_DEST_DIR.mkdir(parents=True, exist_ok=True) + zip_path = NCNN_DEST_DIR / zip_name + + # Download + _install_status = InstallStatus( + state=InstallState.downloading, progress=0, + message=f"Downloading Real-ESRGAN NCNN {NCNN_VERSION}…", + ) + + def _do_download(): + def _progress(count, block, total): + if total > 0: + _install_status.progress = min(85, int(count * block * 85 / total)) + urllib.request.urlretrieve(url, zip_path, _progress) + + await loop.run_in_executor(None, _do_download) + + # Extract + _install_status.state = InstallState.extracting + _install_status.progress = 88 + _install_status.message = "Extracting…" + + def _do_extract(): + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(NCNN_DEST_DIR) + found = list(NCNN_DEST_DIR.rglob(_ncnn_binary_name())) + if not found: + raise FileNotFoundError(f"Binary not found after extract: {_ncnn_binary_name()}") + extracted = found[0] + if extracted != binary_path: + extracted.rename(binary_path) + if "win" not in sys.platform.lower(): + binary_path.chmod( + binary_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH + ) + zip_path.unlink(missing_ok=True) + + await loop.run_in_executor(None, _do_extract) + + # Verify binary actually works + _install_status.state = InstallState.verifying + _install_status.progress = 95 + _install_status.message = "Verifying Vulkan…" + + ok = await loop.run_in_executor(None, _test_ncnn_binary, binary_path) + if not ok: + binary_path.unlink(missing_ok=True) + _install_status = InstallStatus( + state=InstallState.skipped, + message="Binary installed but Vulkan unavailable at runtime — " + "falling back to PyTorch CPU / Lanczos.", + ) + print("[upscale] NCNN binary installed but Vulkan check failed — skipping.") + return None + + _install_status = InstallStatus( + state=InstallState.done, progress=100, + message=f"Real-ESRGAN NCNN installed: {binary_path}", + ) + invalidate_caps_cache() + return binary_path + + except Exception as exc: + _install_status = InstallStatus( + state=InstallState.failed, + error=str(exc), + message="Installation failed.", + ) + print(f"[upscale] NCNN auto-install failed: {exc}") + return None + + +# ── Capability detection ────────────────────────────────────────────────────── + +_caps: Optional[dict] = None + + +def probe_upscale_capabilities() -> dict: + """Detect available upscaling methods. Cached after first call.""" + global _caps + if _caps is not None: + return _caps + + caps = { + "lanczos": True, + "realesrgan_pytorch": False, + "realesrgan_pytorch_device": None, + "realesrgan_ncnn": False, + "realesrgan_ncnn_path": None, + "recommended": "lanczos", + "recommended_label": "Lanczos (no AI upscaler found)", + "methods": ["lanczos"], + "ncnn_install_status": get_install_status(), + } + + # ── PyTorch path ────────────────────────────────────────────────────────── + pytorch_device = None + try: + import torch + if torch.cuda.is_available(): + pytorch_device = "cuda" + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + pytorch_device = "mps" + else: + pytorch_device = "cpu" + except ImportError: + pass + + if pytorch_device: + try: + import realesrgan # noqa: F401 + from basicsr.archs.rrdbnet_arch import RRDBNet # noqa: F401 + caps["realesrgan_pytorch"] = True + caps["realesrgan_pytorch_device"] = pytorch_device + caps["methods"].append("realesrgan_pytorch") + except ImportError: + pass + + # ── NCNN Vulkan binary ──────────────────────────────────────────────────── + ncnn_path = _find_ncnn_binary() + if ncnn_path: + caps["realesrgan_ncnn"] = True + caps["realesrgan_ncnn_path"] = str(ncnn_path) + caps["methods"].append("realesrgan_ncnn") + + # ── Pick recommended ────────────────────────────────────────────────────── + if caps["realesrgan_pytorch"] and pytorch_device in ("cuda", "mps"): + device_label = "CUDA GPU" if pytorch_device == "cuda" else "Apple Silicon" + caps["recommended"] = "realesrgan_pytorch" + caps["recommended_label"] = f"Real-ESRGAN ({device_label})" + elif caps["realesrgan_ncnn"]: + caps["recommended"] = "realesrgan_ncnn" + caps["recommended_label"] = "Real-ESRGAN NCNN (Vulkan)" + elif caps["realesrgan_pytorch"] and pytorch_device == "cpu": + caps["recommended"] = "realesrgan_pytorch" + caps["recommended_label"] = "Real-ESRGAN (CPU — may be slow)" + else: + install_state = _install_status.state + if install_state in (InstallState.downloading, InstallState.extracting, InstallState.verifying): + caps["recommended_label"] = "Lanczos (AI upscaler installing…)" + elif install_state == InstallState.skipped: + caps["recommended_label"] = "Lanczos (headless — no Vulkan GPU)" + else: + caps["recommended_label"] = "Lanczos (no AI upscaler found)" + + _caps = caps + return caps + + +def _find_ncnn_binary() -> Optional[Path]: + found = shutil.which("realesrgan-ncnn-vulkan") + if found: + return Path(found) + candidates = [ + NCNN_DEST_DIR / _ncnn_binary_name(), + Path("/usr/local/bin/realesrgan-ncnn-vulkan"), + Path.home() / ".local/bin/realesrgan-ncnn-vulkan", + Path(r"C:/realesrgan-ncnn-vulkan/realesrgan-ncnn-vulkan.exe"), + Path("/opt/homebrew/bin/realesrgan-ncnn-vulkan"), + ] + for p in candidates: + if p.exists() and os.access(p, os.X_OK): + return p + return None + + +def invalidate_caps_cache(): + global _caps + _caps = None + + +# ── Upscale implementations ─────────────────────────────────────────────────── + +def _to_png_bytes(img: Image.Image) -> bytes: + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def upscale_lanczos(image: Image.Image, scale: float) -> tuple[bytes, str]: + new_w = round(image.width * scale) + new_h = round(image.height * scale) + result = image.resize((new_w, new_h), Image.Resampling.LANCZOS) + return _to_png_bytes(result), "lanczos" + + +def upscale_realesrgan_pytorch(image: Image.Image, scale: float) -> tuple[bytes, str]: + import torch + from basicsr.archs.rrdbnet_arch import RRDBNet + from realesrgan import RealESRGANer + + caps = probe_upscale_capabilities() + device = caps.get("realesrgan_pytorch_device", "cpu") + + model_scale = 2 if scale <= 2.5 else 4 + model = RRDBNet( + num_in_ch=3, num_out_ch=3, num_feat=64, + num_block=23, num_grow_ch=32, scale=model_scale + ) + + model_dir = Path("/app/data/models/realesrgan") + model_dir.mkdir(parents=True, exist_ok=True) + model_path = model_dir / f"RealESRGAN_x{model_scale}plus.pth" + if not model_path.exists(): + model_path = None + + upsampler = RealESRGANer( + scale=model_scale, + model_path=str(model_path) if model_path else None, + model=model, + tile=512, + tile_pad=10, + pre_pad=0, + half=(device == "cuda"), + device=torch.device(device), + ) + + import numpy as np + img_bgr = np.array(image)[:, :, ::-1].copy() + enhanced, _ = upsampler.enhance(img_bgr, outscale=scale) + result = Image.fromarray(enhanced[:, :, ::-1]) + return _to_png_bytes(result), f"realesrgan_pytorch_{device}" + + +def upscale_realesrgan_ncnn(image: Image.Image, scale: float) -> tuple[bytes, str]: + caps = probe_upscale_capabilities() + binary = caps.get("realesrgan_ncnn_path") + if not binary: + raise RuntimeError("realesrgan-ncnn-vulkan binary not found") + + model_scale = 4 if scale > 2.5 else 2 + target_w = round(image.width * scale) + target_h = round(image.height * scale) + + with tempfile.TemporaryDirectory() as tmpdir: + in_path = Path(tmpdir) / "input.png" + out_path = Path(tmpdir) / "output.png" + image.save(in_path, format="PNG") + + cmd = [ + binary, + "-i", str(in_path), "-o", str(out_path), + "-s", str(model_scale), "-n", f"realesrgan-x{model_scale}plus", "-f", "png", + ] + r = subprocess.run(cmd, capture_output=True, timeout=300) + if r.returncode != 0: + raise RuntimeError(f"realesrgan-ncnn-vulkan failed: {r.stderr.decode()}") + + result = Image.open(out_path).convert("RGB") + if result.width != target_w or result.height != target_h: + result = result.resize((target_w, target_h), Image.Resampling.LANCZOS) + + return _to_png_bytes(result), "realesrgan_ncnn" + + +# ── Public entry point ──────────────────────────────────────────────────────── + +def upscale_sync(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]: + """Upscale synchronously. Returns (png_bytes, method_label).""" + caps = probe_upscale_capabilities() + + if method == "auto": + method = caps["recommended"] + + if method == "realesrgan_pytorch": + if caps["realesrgan_pytorch"]: + try: + return upscale_realesrgan_pytorch(image, scale) + except Exception as e: + print(f"Real-ESRGAN PyTorch failed, falling back: {e}") + if caps["realesrgan_ncnn"]: + try: + return upscale_realesrgan_ncnn(image, scale) + except Exception as e: + print(f"Real-ESRGAN NCNN fallback failed: {e}") + return upscale_lanczos(image, scale) + + if method == "realesrgan_ncnn": + if caps["realesrgan_ncnn"]: + try: + return upscale_realesrgan_ncnn(image, scale) + except Exception as e: + print(f"Real-ESRGAN NCNN failed, falling back: {e}") + if caps["realesrgan_pytorch"]: + try: + return upscale_realesrgan_pytorch(image, scale) + except Exception as e: + print(f"Real-ESRGAN PyTorch fallback failed: {e}") + return upscale_lanczos(image, scale) + + return upscale_lanczos(image, scale) + + +async def upscale_image(image: Image.Image, scale: float, method: str = "auto") -> tuple[bytes, str]: + """Async wrapper — runs upscale in thread pool.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, upscale_sync, image, scale, method) diff --git a/paintplus/backend/app/utils/__init__.py b/paintplus/backend/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/paintplus/backend/app/utils/image_processing.py b/paintplus/backend/app/utils/image_processing.py new file mode 100644 index 0000000..978d2e6 --- /dev/null +++ b/paintplus/backend/app/utils/image_processing.py @@ -0,0 +1,228 @@ +from PIL import Image, ImageFilter, ImageDraw +import numpy as np +from io import BytesIO +from typing import Tuple, Dict +import cv2 + + +def bytes_to_image(image_bytes: bytes) -> Image.Image: + """Convert bytes to PIL Image""" + return Image.open(BytesIO(image_bytes)).convert('RGBA') + + +def image_to_bytes(image: Image.Image, format: str = 'PNG') -> bytes: + """Convert PIL Image to bytes""" + buffer = BytesIO() + image.save(buffer, format=format) + return buffer.getvalue() + + +def crop_patch(image: Image.Image, bbox: Dict[str, int]) -> Image.Image: + """ + Crop a patch from the image using bounding box + + Args: + image: PIL Image + bbox: Dictionary with x, y, width, height + + Returns: + Cropped patch as PIL Image + """ + x, y, width, height = bbox['x'], bbox['y'], bbox['width'], bbox['height'] + return image.crop((x, y, x + width, y + height)) + + +def create_feathered_mask(mask: Image.Image, feather_px: int) -> Image.Image: + """ + Apply feathering (Gaussian blur) to mask edges + + Args: + mask: Binary mask image (grayscale) + feather_px: Feather radius in pixels + + Returns: + Feathered mask + """ + if feather_px <= 0: + return mask + + # Apply Gaussian blur for feathering + feathered = mask.filter(ImageFilter.GaussianBlur(radius=feather_px)) + return feathered + + +def blend_patch( + original_patch: Image.Image, + regenerated_patch: Image.Image, + mask: Image.Image, + feather_px: int = 0, + preserve_alpha: bool = True +) -> Image.Image: + """ + Blend regenerated patch with original using mask. + Preserves original alpha channel for semi-transparent areas (veils, glass, etc). + + Args: + original_patch: Original cropped patch + regenerated_patch: AI-regenerated patch + mask: Binary mask (same size as patches) + feather_px: Feather radius for smooth blending + preserve_alpha: If True, preserves original alpha channel + + Returns: + Blended patch with preserved transparency + """ + # Ensure all images are the same size + if regenerated_patch.size != original_patch.size: + regenerated_patch = regenerated_patch.resize(original_patch.size, Image.Resampling.LANCZOS) + + if mask.size != original_patch.size: + mask = mask.resize(original_patch.size, Image.Resampling.LANCZOS) + + # Convert mask to grayscale if needed + if mask.mode != 'L': + mask = mask.convert('L') + + # Apply feathering to mask + feathered_mask = create_feathered_mask(mask, feather_px) + + # Convert images to RGBA, storing original alpha + original_rgba = original_patch.convert('RGBA') + original_alpha = original_rgba.split()[3] # Store original alpha channel + + regenerated_rgba = regenerated_patch.convert('RGBA') + + # Blend using the feathered mask + blended = Image.composite(regenerated_rgba, original_rgba, feathered_mask) + + # Restore original alpha channel to preserve transparency + # This keeps semi-transparent areas (veils, glass, smoke) intact + if preserve_alpha: + r, g, b, _ = blended.split() + blended = Image.merge('RGBA', (r, g, b, original_alpha)) + + return blended + + +def insert_patch( + full_image: Image.Image, + patch: Image.Image, + bbox: Dict[str, int] +) -> Image.Image: + """ + Insert a patch back into the full image at the specified bbox + + Args: + full_image: Full original image + patch: Patch to insert + bbox: Bounding box {x, y, width, height} + + Returns: + Full image with patch inserted + """ + result = full_image.copy() + x, y = bbox['x'], bbox['y'] + + # Ensure patch is the correct size + if patch.size != (bbox['width'], bbox['height']): + patch = patch.resize((bbox['width'], bbox['height']), Image.Resampling.LANCZOS) + + # Paste the patch + result.paste(patch, (x, y), patch if patch.mode == 'RGBA' else None) + + return result + + +def create_mask_from_selection( + width: int, + height: int, + selection_type: str, + selection_data: Dict +) -> Image.Image: + """ + Create a binary mask from selection data + + Args: + width: Mask width + height: Mask height + selection_type: "rectangle", "ellipse", or "lasso" + selection_data: Selection-specific data + + Returns: + Binary mask (white = selected, black = not selected) + """ + mask = Image.new('L', (width, height), 0) + draw = ImageDraw.Draw(mask) + + if selection_type == "rectangle": + # Fill entire rectangle + draw.rectangle([0, 0, width, height], fill=255) + + elif selection_type == "ellipse": + # Fill entire ellipse + draw.ellipse([0, 0, width, height], fill=255) + + elif selection_type == "lasso": + # Draw polygon from points + points = selection_data.get('points', []) + if points: + # Convert points to relative coordinates within bbox + draw.polygon(points, fill=255) + + return mask + + +def ensure_even_dimensions(image: Image.Image) -> Image.Image: + """ + Ensure image dimensions are even numbers (required by some AI providers) + + Args: + image: PIL Image + + Returns: + Image with even dimensions + """ + width, height = image.size + new_width = width if width % 2 == 0 else width + 1 + new_height = height if height % 2 == 0 else height + 1 + + if (new_width, new_height) != (width, height): + new_image = Image.new(image.mode, (new_width, new_height), (0, 0, 0, 0)) + new_image.paste(image, (0, 0)) + return new_image + + return image + + +def resize_for_ai(image: Image.Image, max_size: int = 1024) -> Tuple[Image.Image, float]: + """ + Resize image if needed for AI processing (max dimension) + + Args: + image: PIL Image + max_size: Maximum dimension size + + Returns: + Tuple of (resized image, scale factor) + """ + width, height = image.size + max_dim = max(width, height) + + if max_dim > max_size: + scale = max_size / max_dim + new_width = int(width * scale) + new_height = int(height * scale) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + return ensure_even_dimensions(resized), scale + + return ensure_even_dimensions(image), 1.0 + + +def scale_bbox(bbox: Dict[str, int], scale: float) -> Dict[str, int]: + """Scale bounding box coordinates""" + return { + 'x': int(bbox['x'] * scale), + 'y': int(bbox['y'] * scale), + 'width': int(bbox['width'] * scale), + 'height': int(bbox['height'] * scale) + } diff --git a/paintplus/backend/entrypoint.sh b/paintplus/backend/entrypoint.sh new file mode 100644 index 0000000..f51828e --- /dev/null +++ b/paintplus/backend/entrypoint.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# ============================================================================= +# AI Photo Edit - Container Startup Script +# ============================================================================= +# This script runs when the container starts. It: +# 1. Initializes the database +# 2. Downloads SAM model automatically (can be disabled with AUTO_DOWNLOAD_SAM=false) +# 3. Downloads U2Net model automatically (can be disabled with AUTO_DOWNLOAD_U2NET=false) +# 4. Downloads sample eye images if the catalog is empty +# 5. Starts the FastAPI server +# ============================================================================= + +set -e + +echo "==========================================" +echo "AI Photo Edit - Starting Up" +echo "==========================================" + +# Ensure data directories exist +mkdir -p /app/data/projects +mkdir -p /app/data/patches +mkdir -p /app/data/models +mkdir -p /app/data/patch_library + +# Initialize database FIRST (before eye import) +echo "" +echo "Initializing database..." +echo "------------------------------------------" +cd /app && python /scripts/init_database.py || echo "Warning: Database init failed (non-fatal)" + +# Check and download SAM model automatically +echo "" +echo "Checking SAM model (Smart Select)..." +echo "------------------------------------------" +if [ -f "/app/data/models/sam_model.pth" ] || \ + [ -f "/app/data/models/sam_vit_b_01ec64.pth" ] || \ + [ -f "/app/data/models/sam_vit_l_0b3195.pth" ] || \ + [ -f "/app/data/models/sam_vit_h_4b8939.pth" ]; then + echo "✓ SAM model found - Smart Select will use local AI (free, offline)" +else + # Auto-download SAM unless explicitly disabled + AUTO_DOWNLOAD_SAM="${AUTO_DOWNLOAD_SAM:-true}" + if [ "$AUTO_DOWNLOAD_SAM" = "true" ]; then + echo "SAM model not found. Downloading automatically..." + echo "(This is a one-time ~375MB download that persists across rebuilds)" + echo "" + python /scripts/download_sam_model.py vit_b || { + echo "" + echo "⚠ SAM download failed (non-fatal)" + echo " Smart Select will fall back to Replicate API (requires REPLICATE_API_KEY)" + echo " To retry later: docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py" + } + else + echo "" + echo "⚠ SAM model not found (AUTO_DOWNLOAD_SAM=false)" + echo "" + echo " Smart Select will use Replicate API (requires REPLICATE_API_KEY)" + echo "" + echo " To enable FREE offline Smart Select, run:" + echo " docker exec -it ai-photo-edit-backend python /scripts/download_sam_model.py" + echo "" + fi +fi + +echo "" +echo "Checking U2Net model (Remove Background)..." +echo "------------------------------------------" +if [ -f "/app/data/models/u2net.onnx" ] || [ -f "/app/data/models/u2netp.onnx" ]; then + echo "✓ U2Net model found - Remove Background will use local AI (free, offline)" +else + # Auto-download U2Net unless explicitly disabled + AUTO_DOWNLOAD_U2NET="${AUTO_DOWNLOAD_U2NET:-true}" + if [ "$AUTO_DOWNLOAD_U2NET" = "true" ]; then + echo "U2Net model not found. Downloading automatically..." + echo "(This is a one-time ~176MB download that persists across rebuilds)" + echo "" + python /scripts/download_u2net_model.py u2net || { + echo "" + echo "⚠ U2Net download failed (non-fatal)" + echo " Remove Background will fall back to rembg (if installed)" + echo " To retry later: docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py u2net" + } + else + echo "" + echo "⚠ U2Net model not found (AUTO_DOWNLOAD_U2NET=false)" + echo "" + echo " Remove Background will fall back to rembg (if installed)" + echo "" + echo " To enable FREE offline Remove Background, run:" + echo " docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py u2net" + echo "" + fi +fi + +echo "" +echo "Checking GPU capabilities..." +echo "------------------------------------------" +python /scripts/gpu_setup.py || echo "Warning: GPU detection failed (non-fatal)" + +echo "" +echo "==========================================" +echo "Starting FastAPI server..." +echo "==========================================" + +# Start the server +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 diff --git a/paintplus/backend/requirements.gpu.txt b/paintplus/backend/requirements.gpu.txt new file mode 100644 index 0000000..fd25964 --- /dev/null +++ b/paintplus/backend/requirements.gpu.txt @@ -0,0 +1,44 @@ +# ============================================================================= +# GPU / Local Diffusion dependencies +# Install alongside requirements.txt when running with AI_PROVIDER=local_gpu +# +# Usage: +# pip install -r requirements.txt -r requirements.gpu.txt +# +# These are pre-installed in Dockerfile.gpu; optional in the standard image. +# ============================================================================= + +# HuggingFace Diffusers ecosystem +# Pinned <0.29.0: diffusers 0.29.0 added torch.xpu (Intel GPU) which fails on +# PyTorch 2.1.x with "AttributeError: module 'torch' has no attribute 'xpu'". +# Upgrade the base image in Dockerfile.gpu to pytorch 2.4+ before lifting this pin. +# (FLUX support requires diffusers>=0.29 + PyTorch>=2.4; SDXL/SD works fine here.) +diffusers>=0.28.0,<0.29.0 +transformers>=4.36.0,<4.40.0 +accelerate>=0.27.0 +huggingface-hub>=0.23.0 +safetensors>=0.4.0 + +# Required by SDXL pipelines +invisible-watermark>=0.2.0 +omegaconf>=2.3.0 + +# Required by FLUX (T5 text encoder tokenizer) +sentencepiece>=0.2.0 + +# xformers — reduces attention VRAM ~20-30%, often unlocks the next model tier +# Must match your PyTorch+CUDA version; leave out if unsure. +# Install post-container-start if needed: +# pip install xformers --index-url https://download.pytorch.org/whl/cu121 +# xformers + +# Background removal — BEN2 (default, clean cutouts/hair) + BiRefNet-HR +# (high-res/print alternate). Both MIT-licensed. Verified against upstream +# source: neither requires torch>=2.5 despite the BiRefNet repo's own +# requirements.txt floor — that pin is for its training/eval scripts, not +# the inference path used here. Weights download from HuggingFace on first +# use (cached via the hf_cache bind mount, same as the diffusion models). +ben2 @ git+https://github.com/PramaLLC/BEN2.git +timm>=1.0.10 +einops>=0.6.0 +kornia>=0.7.0 diff --git a/paintplus/backend/requirements.txt b/paintplus/backend/requirements.txt new file mode 100644 index 0000000..28e4923 --- /dev/null +++ b/paintplus/backend/requirements.txt @@ -0,0 +1,25 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 +Pillow>=10.0.0,<11.0.0 +numpy<2.0.0 +sqlalchemy==2.0.25 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 +aiofiles==23.2.1 +httpx==0.26.0 +pydantic==2.5.3 +pydantic-settings==2.1.0 +email-validator==2.1.0 +opencv-python-headless>=4.10.0 +# SAM (Segment Anything) for smart object selection - runs locally, no API needed +torch==2.1.2 +torchvision==0.16.2 +segment-anything @ git+https://github.com/facebookresearch/segment-anything.git + +# Local AI inpainting — LaMa model (auto GPU/CPU, no API key needed) +simple-lama-inpainting + +# Background removal — rembg enabled now that opencv 4.10+ supports numpy 2.x +rembg[gpu] diff --git a/paintplus/backend/scripts/README.md b/paintplus/backend/scripts/README.md new file mode 100644 index 0000000..2dc3487 --- /dev/null +++ b/paintplus/backend/scripts/README.md @@ -0,0 +1,225 @@ +# Eye Catalog Import Scripts + +Tools for populating your carved eye catalog with public domain examples. + +--- + +## Quick Start + +### 1. Download Classical Eyes + +Follow the guide: `/docs/PUBLIC_DOMAIN_EYE_SOURCES.md` + +**Best sources:** +- Metropolitan Museum (CC0) +- Smithsonian Open Access +- Getty Museum Open Content + +**Download 10-20 high-res images** of carved eyes from classical sculptures. + +--- + +### 2. Crop the Eyes + +Use any image editor (Photoshop, GIMP, Preview, etc.): + +1. Open statue photo +2. Zoom in on eye +3. Crop just the eye (include eyelids, socket, tear duct) +4. Save as PNG with descriptive name: + - `greek_serene_left.png` + - `roman_fierce_right.png` + - `egyptian_wise_left.png` + +--- + +### 3. Import to Catalog + +**Easy way (one at a time):** +```bash +cd backend/scripts + +# Import a Greek serene eye +python import_eyes.py greek_serene_left.png \ + --emotion serene \ + --side left \ + --style greek + +# Import a Roman fierce eye +python import_eyes.py roman_fierce_right.png \ + --emotion fierce \ + --side right \ + --style roman +``` + +**Batch import:** +```bash +# Import all Greek eyes at once +python import_eyes.py greek_*.png \ + --emotion serene \ + --side both \ + --style greek + +# Import all Roman eyes +python import_eyes.py roman_*.png \ + --emotion fierce \ + --side both \ + --style roman +``` + +--- + +## Available Options + +### Emotions +- `serene` - Peaceful, calm (most classical Greek) +- `fierce` - Intense, powerful (Hellenistic, Alexander) +- `wise` - Aged, experienced (Roman senators) +- `peaceful` - Gentle, kind (Archaic Greek) +- `joyful` - Happy, smiling (rare in classical) +- `sorrowful` - Sad, mourning (some Hellenistic) +- `neutral` - Default, no strong emotion + +### Styles +- `greek` - Classical Greek (450-400 BCE) +- `roman` - Roman Republican/Imperial +- `egyptian` - Ancient Egyptian carved eyes +- `renaissance` - Renaissance sculpture +- `baroque` - Baroque period +- `modern` - Contemporary carving +- `custom` - Your own style + +### Sides +- `left` - Left eye +- `right` - Right eye +- `both` - Can be used for either (symmetric) + +--- + +## Example Workflow + +### Build a Complete Catalog + +```bash +# 1. Download eyes from Met Museum +# (See PUBLIC_DOMAIN_EYE_SOURCES.md) + +# 2. Crop and save them: +# greek_serene_left.png +# greek_serene_right.png +# roman_fierce_left.png +# roman_fierce_right.png +# etc. + +# 3. Import them all: + +python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek +python import_eyes.py greek_serene_right.png --emotion serene --side right --style greek +python import_eyes.py roman_fierce_left.png --emotion fierce --side left --style roman +python import_eyes.py roman_fierce_right.png --emotion fierce --side right --style roman + +# Or batch: +python import_eyes.py greek_*.png --emotion serene --side both --style greek +python import_eyes.py roman_*.png --emotion fierce --side both --style roman +``` + +--- + +## Recommended Starting Collection + +### 10 Essential Eyes + +1. **Greek Serene Left** (peaceful carvings) +2. **Greek Serene Right** +3. **Greek Archaic Left** (stylized, simple) +4. **Greek Archaic Right** +5. **Roman Fierce Left** (powerful portraits) +6. **Roman Fierce Right** +7. **Roman Wise Left** (aged, realistic) +8. **Roman Wise Right** +9. **Egyptian Stylized Left** (distinctive style) +10. **Egyptian Stylized Right** + +This gives you 5 styles/emotions to start! + +--- + +## Check Your Catalog + +### Via API + +```bash +# List all eyes in catalog +curl http://localhost:8101/patches/?category=carved_eye + +# Filter by emotion +curl http://localhost:8101/patches/?category=carved_eye&tags=serene + +# Filter by style +curl http://localhost:8101/patches/?tags=greek +``` + +### Via Web Interface + +Go to: `http://your-server:3080` + +Navigate to patch library to browse your eyes visually. + +--- + +## Advanced: Seed Script + +For batch importing from the `seed_data/eyes/` directory: + +```bash +# 1. Place all cropped eyes in: +mkdir -p seed_data/eyes/ +# Copy your eye images there + +# 2. Edit seed_eye_catalog.py to add metadata + +# 3. Run: +python seed_eye_catalog.py +``` + +This auto-imports all eyes in `seed_data/eyes/` with pre-configured metadata. + +--- + +## Tips + +1. **High resolution:** Use images 1000px+ for best results +2. **Clean crops:** Include some surrounding area, not just the eyeball +3. **Consistent naming:** Use descriptive filenames +4. **Test first:** Import 2-3 eyes to test the workflow +5. **Build gradually:** Start with 10 eyes, expand as needed + +--- + +## Troubleshooting + +**"File not found":** +- Make sure you're in `backend/scripts/` directory +- Use full path or relative path to image + +**"Database connection error":** +- Make sure backend is running: `docker compose up backend` +- Check database exists: `ls -la ../../data/` + +**"Import failed":** +- Check image format (PNG, JPG supported) +- Verify file isn't corrupted +- Check file permissions + +--- + +## Next Steps + +After importing eyes: + +1. **Test them:** Apply to a colored photo via API +2. **Refine:** Add more variations as needed +3. **Build library:** Aim for 20-30 eyes covering all emotions +4. **Share:** Your best eyes can be exported and shared + +**Your catalog of master sculptor's eyes is ready to use!** diff --git a/paintplus/backend/scripts/import_eyes.py b/paintplus/backend/scripts/import_eyes.py new file mode 100755 index 0000000..216f524 --- /dev/null +++ b/paintplus/backend/scripts/import_eyes.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Quick eye import script + +Usage: + python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek + python import_eyes.py *.png --emotion fierce --style roman + +This will add eyes to the patch library with proper metadata. +""" + +import sys +import argparse +from pathlib import Path +from PIL import Image + +# Add parent directory to path to import app modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.database import SessionLocal +from app.models.patch import Patch +from app.services.patch_library import PatchLibraryService + + +def import_eye( + image_path: Path, + emotion: str, + side: str, + style: str, + description: str = None +): + """ + Import a single eye image into the catalog + + Args: + image_path: Path to eye image file + emotion: serene, fierce, wise, peaceful, joyful, sorrowful + side: left, right, both + style: greek, roman, egyptian, renaissance, custom + description: Optional custom description + """ + + db = SessionLocal() + patch_service = PatchLibraryService() + + # Generate name from filename if not provided + name = image_path.stem.replace('_', ' ').title() + + # Auto-generate description if not provided + if not description: + description = f"{style.title()} carved eye, {emotion} expression, {side} side. Suitable for CNC wood carving." + + # Generate tags + tags = f"{style}, {emotion}, {side}, carved, cnc-ready, wood-carving" + + print(f"\n📸 Importing: {name}") + print(f" File: {image_path.name}") + print(f" Style: {style}") + print(f" Emotion: {emotion}") + print(f" Side: {side}") + + try: + # Create patch record + patch = Patch( + name=name, + description=description, + source_type="imported", + category="carved_eye", + tags=tags, + width=0, + height=0, + user_id=None, + file_path="" + ) + + db.add(patch) + db.commit() + db.refresh(patch) + + # Save image file + file_path = patch_service.save_patch_from_file( + patch.id, + str(image_path), + create_thumb=True + ) + + # Get and update dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + patch.file_path = file_path + patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id)) + + db.commit() + + print(f"✅ Successfully imported!") + print(f" ID: {patch.id}") + print(f" Size: {width}x{height}px") + + return patch.id + + except Exception as e: + print(f"❌ Error importing: {e}") + db.rollback() + return None + + finally: + db.close() + + +def main(): + parser = argparse.ArgumentParser( + description="Import carved eye images into the patch library", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Import a single eye + python import_eyes.py greek_serene_left.png --emotion serene --side left --style greek + + # Import multiple eyes with same metadata + python import_eyes.py roman_*.png --emotion fierce --side both --style roman + + # With custom description + python import_eyes.py statue_eye.png --emotion wise --side right --style roman --description "Emperor Augustus portrait eye" + +Emotions: serene, fierce, wise, peaceful, joyful, sorrowful, neutral +Sides: left, right, both +Styles: greek, roman, egyptian, renaissance, baroque, modern, custom + """ + ) + + parser.add_argument( + 'images', + nargs='+', + help='Image file(s) to import (supports wildcards)' + ) + + parser.add_argument( + '--emotion', + required=True, + choices=['serene', 'fierce', 'wise', 'peaceful', 'joyful', 'sorrowful', 'neutral'], + help='Emotional expression of the eye' + ) + + parser.add_argument( + '--side', + required=True, + choices=['left', 'right', 'both'], + help='Which eye (left or right)' + ) + + parser.add_argument( + '--style', + required=True, + choices=['greek', 'roman', 'egyptian', 'renaissance', 'baroque', 'modern', 'custom'], + help='Carving style/period' + ) + + parser.add_argument( + '--description', + help='Custom description (optional)' + ) + + args = parser.parse_args() + + # Resolve wildcards and get all image files + image_files = [] + for pattern in args.images: + path = Path(pattern) + if '*' in pattern: + # Wildcard - expand it + parent = path.parent if path.parent.exists() else Path('.') + image_files.extend(parent.glob(path.name)) + else: + # Single file + if path.exists(): + image_files.append(path) + else: + print(f"⚠️ File not found: {pattern}") + + if not image_files: + print("❌ No image files found!") + return + + print(f"\n🎨 Importing {len(image_files)} eye image(s) into catalog...") + print(f" Style: {args.style}") + print(f" Emotion: {args.emotion}") + print(f" Side: {args.side}") + print("="*60) + + imported_count = 0 + for image_path in image_files: + patch_id = import_eye( + image_path, + args.emotion, + args.side, + args.style, + args.description + ) + if patch_id: + imported_count += 1 + + print("="*60) + print(f"\n✅ Import complete! {imported_count}/{len(image_files)} eyes added to catalog") + print(f"\n💡 Access your eye catalog at: http://your-server:3080") + print(f" Or via API: GET /patches/?category=carved_eye") + + +if __name__ == "__main__": + main() diff --git a/paintplus/backend/scripts/seed_eye_catalog.py b/paintplus/backend/scripts/seed_eye_catalog.py new file mode 100644 index 0000000..bee23fc --- /dev/null +++ b/paintplus/backend/scripts/seed_eye_catalog.py @@ -0,0 +1,176 @@ +""" +Seed the patch library with classical carved eyes from public domain sources + +This script helps pre-populate the eye catalog with examples from: +- Greek statues (Metropolitan Museum, Louvre) +- Roman sculptures (Smithsonian, British Museum) +- Renaissance carvings +- Ancient Egyptian carved eyes + +All images should be public domain (CC0, Public Domain Mark) +""" + +import asyncio +import httpx +from pathlib import Path +from PIL import Image +from io import BytesIO + +# Public domain eye examples to seed the catalog +# These are examples - you would add actual URLs from museum APIs +CLASSICAL_EYES = [ + { + "name": "Greek Statue - Serene Left Eye", + "description": "Classical Greek marble carving, convex eyeball, defined upper lid, deep socket. Perfect for serene expressions.", + "category": "carved_eye", + "tags": "greek, serene, left, marble, classical, convex, deep-socket", + "style": "greek_classical", + "emotion": "serene", + "side": "left", + "source_url": "https://images.metmuseum.org/...", # Example + "source": "Metropolitan Museum of Art - Public Domain" + }, + { + "name": "Roman Sculpture - Fierce Right Eye", + "description": "Roman marble, prominent brow ridge, intense gaze, sharp eyelid definition.", + "category": "carved_eye", + "tags": "roman, fierce, right, marble, intense, sharp-detail", + "style": "roman_classical", + "emotion": "fierce", + "side": "right", + "source_url": "https://...", + "source": "Smithsonian - CC0" + }, + { + "name": "Greek Kouros - Peaceful Left Eye", + "description": "Archaic Greek style, almond-shaped, subtle carving, peaceful expression.", + "category": "carved_eye", + "tags": "greek, peaceful, left, archaic, almond-shaped, subtle", + "style": "greek_archaic", + "emotion": "peaceful", + "side": "left", + "source_url": "https://...", + "source": "Getty Museum - Public Domain" + }, + { + "name": "Roman Portrait - Wise Right Eye", + "description": "Late Roman period, detailed eyelids, slight downward gaze, wisdom and age.", + "category": "carved_eye", + "tags": "roman, wise, right, portrait, detailed, aged", + "style": "roman_portrait", + "emotion": "wise", + "side": "right", + "source_url": "https://...", + "source": "British Museum - CC0" + }, +] + +async def download_image(url: str) -> bytes: + """Download image from URL""" + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.content + +async def crop_eye_from_statue(image_bytes: bytes, crop_box: tuple) -> bytes: + """ + Crop just the eye from a full statue photo + + Args: + image_bytes: Full statue image + crop_box: (left, top, right, bottom) coordinates + + Returns: + Cropped eye image bytes + """ + img = Image.open(BytesIO(image_bytes)) + eye = img.crop(crop_box) + + # Save as PNG + buffer = BytesIO() + eye.save(buffer, format='PNG') + return buffer.getvalue() + +async def seed_eye_catalog(): + """ + Seed the patch library with classical carved eyes + + NOTE: This is a template. You need to: + 1. Get actual public domain image URLs + 2. Manually crop the eyes (or provide crop coordinates) + 3. Run this to populate the catalog + """ + + from app.database import SessionLocal + from app.models.patch import Patch + from app.services.patch_library import PatchLibraryService + + db = SessionLocal() + patch_service = PatchLibraryService() + + print("Seeding eye catalog with classical carved eyes...") + + for eye_data in CLASSICAL_EYES: + print(f"\nAdding: {eye_data['name']}") + + # Create patch record + patch = Patch( + name=eye_data['name'], + description=eye_data['description'], + source_type="imported", + category=eye_data['category'], + tags=eye_data['tags'], + width=0, # Will be set after image save + height=0, + user_id=None, + file_path="" + ) + + db.add(patch) + db.commit() + db.refresh(patch) + + # Download and save image + # NOTE: You need to manually download/crop these first + # This is just the structure + + try: + # image_bytes = await download_image(eye_data['source_url']) + # cropped_eye = await crop_eye_from_statue(image_bytes, crop_box) + + # For now, you would manually place images in: + # ./seed_data/eyes/greek_serene_left.png + # ./seed_data/eyes/roman_fierce_right.png + # etc. + + seed_image_path = Path(__file__).parent / "seed_data" / "eyes" / f"{eye_data['style']}_{eye_data['emotion']}_{eye_data['side']}.png" + + if seed_image_path.exists(): + file_path = patch_service.save_patch_from_file( + patch.id, + str(seed_image_path), + create_thumb=True + ) + + # Get dimensions + width, height = patch_service.get_patch_size(patch.id) + patch.width = width + patch.height = height + patch.file_path = file_path + patch.thumbnail_path = str(patch_service.get_thumbnail_path(patch.id)) + + db.commit() + print(f"✅ Added {eye_data['name']}") + else: + print(f"⚠️ Image not found: {seed_image_path}") + print(f" Please download and crop eye from: {eye_data['source']}") + + except Exception as e: + print(f"❌ Error adding {eye_data['name']}: {e}") + db.rollback() + + db.close() + print("\n✅ Eye catalog seeding complete!") + +if __name__ == "__main__": + asyncio.run(seed_eye_catalog()) diff --git a/paintplus/bring-up-local-gpu.sh b/paintplus/bring-up-local-gpu.sh new file mode 100755 index 0000000..fbf78b3 --- /dev/null +++ b/paintplus/bring-up-local-gpu.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# bring-up-local-gpu.sh — start the GPU container. +# +# Run this each time you want to start the app. +# Run ./install-local-gpu.sh once first on a new machine. +# +# Before starting, this fetches any missing models on the host (outside +# Docker) via ./prefetch-models.sh — in-container DNS/network is unreliable +# on some hosts, so this is the default now, not a manual troubleshooting +# step. It never blocks startup: if it fails (no network, no python3, etc.) +# the container still starts and falls back to its own in-container download. +# +# Usage: +# ./bring-up-local-gpu.sh # start (detached, rebuild if needed) +# ./bring-up-local-gpu.sh --no-build # start without rebuilding +# ./bring-up-local-gpu.sh down # stop and remove container +# ./bring-up-local-gpu.sh logs -f # tail logs +# +# Force pip layer rebuild (e.g. after requirements change): +# BUILDID=$(date +%s) ./bring-up-local-gpu.sh + +set -euo pipefail + +cd "$(dirname "$0")" + +# Pre-create ./data as the current (non-root) user. Otherwise, on a fresh +# checkout, Docker's daemon (root) auto-creates these bind-mount sources on +# the first 'up' — leaving them root-owned and blocking this same user from +# later writing to them without sudo (e.g. ./prefetch-models.sh). No-op if +# they already exist, regardless of current ownership. +mkdir -p data/models data/hf_cache data/projects data/patches + +if [ $# -eq 0 ]; then + # Best-effort: fetch any missing models on the host first (see header). + ./prefetch-models.sh --sdxl \ + || echo "⚠ Model prefetch had failures (see above) — continuing anyway, the container will retry in-container." + exec docker compose -f docker-compose.gpu.yml up -d --build +else + exec docker compose -f docker-compose.gpu.yml "$@" +fi diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_amber.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_amber.ppm new file mode 100644 index 0000000..da6b5af Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_amber.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_blue.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_blue.ppm new file mode 100644 index 0000000..b53cdbc Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_blue.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_brown.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_brown.ppm new file mode 100644 index 0000000..f8a6081 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_brown.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_green.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_green.ppm new file mode 100644 index 0000000..9ddd492 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_green.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_grey.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_grey.ppm new file mode 100644 index 0000000..7d2fecd Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_grey.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_anime_hazel.ppm b/paintplus/data/classic_eyes_ppm/classic_anime_hazel.ppm new file mode 100644 index 0000000..1a7e407 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_anime_hazel.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_amber.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_amber.ppm new file mode 100644 index 0000000..d941767 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_amber.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_blue.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_blue.ppm new file mode 100644 index 0000000..ad5d925 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_blue.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_brown.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_brown.ppm new file mode 100644 index 0000000..281f8ad Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_brown.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_green.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_green.ppm new file mode 100644 index 0000000..42dc719 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_green.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_grey.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_grey.ppm new file mode 100644 index 0000000..381e948 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_grey.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_cartoon_hazel.ppm b/paintplus/data/classic_eyes_ppm/classic_cartoon_hazel.ppm new file mode 100644 index 0000000..c65809e Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_cartoon_hazel.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_amber.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_amber.ppm new file mode 100644 index 0000000..8d983a9 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_amber.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_blue.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_blue.ppm new file mode 100644 index 0000000..961bb5a Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_blue.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_brown.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_brown.ppm new file mode 100644 index 0000000..3a9c118 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_brown.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_green.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_green.ppm new file mode 100644 index 0000000..f10663f Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_green.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_grey.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_grey.ppm new file mode 100644 index 0000000..b4c0744 Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_grey.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/classic_realistic_hazel.ppm b/paintplus/data/classic_eyes_ppm/classic_realistic_hazel.ppm new file mode 100644 index 0000000..2bf282e Binary files /dev/null and b/paintplus/data/classic_eyes_ppm/classic_realistic_hazel.ppm differ diff --git a/paintplus/data/classic_eyes_ppm/convert_to_png.py b/paintplus/data/classic_eyes_ppm/convert_to_png.py new file mode 100644 index 0000000..49dbcde --- /dev/null +++ b/paintplus/data/classic_eyes_ppm/convert_to_png.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Convert PPM files to PNG using PIL.""" +from pathlib import Path +from PIL import Image + +ppm_dir = Path(__file__).parent +for ppm_file in ppm_dir.glob("*.ppm"): + png_file = ppm_file.with_suffix('.png') + img = Image.open(ppm_file) + img.save(png_file, 'PNG') + print(f"Converted: {ppm_file.name} -> {png_file.name}") diff --git a/paintplus/data/classic_eyes_ppm/metadata.json b/paintplus/data/classic_eyes_ppm/metadata.json new file mode 100644 index 0000000..253f699 --- /dev/null +++ b/paintplus/data/classic_eyes_ppm/metadata.json @@ -0,0 +1,164 @@ +[ + { + "filename": "classic_realistic_blue.png", + "ppm_filename": "classic_realistic_blue.ppm", + "name": "Classic Realistic Eye - Blue", + "description": "A realistic style eye with blue iris color", + "tags": "eye,classic,realistic,blue,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_realistic_green.png", + "ppm_filename": "classic_realistic_green.ppm", + "name": "Classic Realistic Eye - Green", + "description": "A realistic style eye with green iris color", + "tags": "eye,classic,realistic,green,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_realistic_brown.png", + "ppm_filename": "classic_realistic_brown.ppm", + "name": "Classic Realistic Eye - Brown", + "description": "A realistic style eye with brown iris color", + "tags": "eye,classic,realistic,brown,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_realistic_hazel.png", + "ppm_filename": "classic_realistic_hazel.ppm", + "name": "Classic Realistic Eye - Hazel", + "description": "A realistic style eye with hazel iris color", + "tags": "eye,classic,realistic,hazel,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_realistic_grey.png", + "ppm_filename": "classic_realistic_grey.ppm", + "name": "Classic Realistic Eye - Grey", + "description": "A realistic style eye with grey iris color", + "tags": "eye,classic,realistic,grey,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_realistic_amber.png", + "ppm_filename": "classic_realistic_amber.ppm", + "name": "Classic Realistic Eye - Amber", + "description": "A realistic style eye with amber iris color", + "tags": "eye,classic,realistic,amber,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_blue.png", + "ppm_filename": "classic_anime_blue.ppm", + "name": "Classic Anime Eye - Blue", + "description": "A anime style eye with blue iris color", + "tags": "eye,classic,anime,blue,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_green.png", + "ppm_filename": "classic_anime_green.ppm", + "name": "Classic Anime Eye - Green", + "description": "A anime style eye with green iris color", + "tags": "eye,classic,anime,green,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_brown.png", + "ppm_filename": "classic_anime_brown.ppm", + "name": "Classic Anime Eye - Brown", + "description": "A anime style eye with brown iris color", + "tags": "eye,classic,anime,brown,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_hazel.png", + "ppm_filename": "classic_anime_hazel.ppm", + "name": "Classic Anime Eye - Hazel", + "description": "A anime style eye with hazel iris color", + "tags": "eye,classic,anime,hazel,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_grey.png", + "ppm_filename": "classic_anime_grey.ppm", + "name": "Classic Anime Eye - Grey", + "description": "A anime style eye with grey iris color", + "tags": "eye,classic,anime,grey,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_anime_amber.png", + "ppm_filename": "classic_anime_amber.ppm", + "name": "Classic Anime Eye - Amber", + "description": "A anime style eye with amber iris color", + "tags": "eye,classic,anime,amber,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_blue.png", + "ppm_filename": "classic_cartoon_blue.ppm", + "name": "Classic Cartoon Eye - Blue", + "description": "A cartoon style eye with blue iris color", + "tags": "eye,classic,cartoon,blue,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_green.png", + "ppm_filename": "classic_cartoon_green.ppm", + "name": "Classic Cartoon Eye - Green", + "description": "A cartoon style eye with green iris color", + "tags": "eye,classic,cartoon,green,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_brown.png", + "ppm_filename": "classic_cartoon_brown.ppm", + "name": "Classic Cartoon Eye - Brown", + "description": "A cartoon style eye with brown iris color", + "tags": "eye,classic,cartoon,brown,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_hazel.png", + "ppm_filename": "classic_cartoon_hazel.ppm", + "name": "Classic Cartoon Eye - Hazel", + "description": "A cartoon style eye with hazel iris color", + "tags": "eye,classic,cartoon,hazel,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_grey.png", + "ppm_filename": "classic_cartoon_grey.ppm", + "name": "Classic Cartoon Eye - Grey", + "description": "A cartoon style eye with grey iris color", + "tags": "eye,classic,cartoon,grey,medium", + "width": 200, + "height": 200 + }, + { + "filename": "classic_cartoon_amber.png", + "ppm_filename": "classic_cartoon_amber.ppm", + "name": "Classic Cartoon Eye - Amber", + "description": "A cartoon style eye with amber iris color", + "tags": "eye,classic,cartoon,amber,medium", + "width": 200, + "height": 200 + } +] \ No newline at end of file diff --git a/paintplus/data/projects/.gitkeep b/paintplus/data/projects/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/paintplus/docker-compose.dev.yml b/paintplus/docker-compose.dev.yml new file mode 100644 index 0000000..57f6455 --- /dev/null +++ b/paintplus/docker-compose.dev.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: paintplus-backend-dev + ports: + - "8000:8000" + volumes: + - ./data:/app/data + - ./backend:/app + environment: + - DATABASE_URL=sqlite:///./data/ai_photo_edit.db + - SECRET_KEY=dev-secret-key-change-in-production + - AI_PROVIDER=${AI_PROVIDER:-mock} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - STABILITY_API_KEY=${STABILITY_API_KEY:-} + - CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://localhost + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + restart: unless-stopped + networks: + - paintplus-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: paintplus-frontend-dev + ports: + - "5173:5173" + volumes: + - ./frontend:/app + - /app/node_modules + environment: + - VITE_API_BASE_URL=http://localhost:8000 + depends_on: + - backend + restart: unless-stopped + networks: + - paintplus-network + +networks: + paintplus-network: + driver: bridge diff --git a/paintplus/docker-compose.gpu.yml b/paintplus/docker-compose.gpu.yml new file mode 100644 index 0000000..0e50903 --- /dev/null +++ b/paintplus/docker-compose.gpu.yml @@ -0,0 +1,148 @@ +# ============================================================================= +# EditmaskwithAI — GPU Docker Compose (NVIDIA CUDA) +# +# ── PREREQUISITES ───────────────────────────────────────────────────────────── +# +# 1. NVIDIA driver ≥ 525 installed on the host +# Check: nvidia-smi +# +# 2. nvidia-container-toolkit installed and configured: +# (Ubuntu/Debian) +# curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ +# | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-ctk.gpg +# curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ +# | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-ctk.gpg] https://#g' \ +# | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list +# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit +# sudo nvidia-ctk runtime configure --runtime=docker +# sudo systemctl restart docker +# +# (RHEL/Fedora/Rocky) +# sudo dnf install -y nvidia-container-toolkit +# sudo nvidia-ctk runtime configure --runtime=docker +# sudo systemctl restart docker +# +# 3. Verify GPU access in Docker: +# docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi +# +# ── QUICK START ─────────────────────────────────────────────────────────────── +# +# docker compose -f docker-compose.gpu.yml up --build +# Then open: http://localhost:3080 +# +# ── OLDER DOCKER SETUPS (docker-compose v1 / nvidia-docker2) ───────────────── +# +# If you installed nvidia-docker2 (older approach) instead of nvidia-container-toolkit, +# replace the 'deploy:' block below with: +# +# runtime: nvidia +# environment: +# - NVIDIA_VISIBLE_DEVICES=all +# - NVIDIA_DRIVER_CAPABILITIES=compute,utility +# +# ── GPU TIER AUTO-SELECTION ─────────────────────────────────────────────────── +# +# ≥16 GB VRAM → SDXL (best quality) +# 8–16 GB → SDXL +# 4–8 GB → Stable Diffusion 2.x +# 2–4 GB → Stable Diffusion 1.5 (older GPUs: GTX 970/1060/RX 580) +# <2 GB → SD 1.5 + CPU offload (very slow — consider a remote provider) +# +# ── AMD ROCm ────────────────────────────────────────────────────────────────── +# +# Swap the base image in Dockerfile.gpu: +# FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-runtime +# → FROM rocm/pytorch:rocm6.0_ubuntu22.04_py3.9_pytorch_2.1.0 +# Remove the 'driver: nvidia' line and add: device_ids: ['0'] +# +# ============================================================================= + +services: + app: + build: + context: . + dockerfile: Dockerfile.gpu + args: + # Increment BUILDID to force pip layers to re-run without full --no-cache: + # BUILDID=$(date +%s) docker compose -f docker-compose.gpu.yml up --build + BUILDID: ${BUILDID:-1} + container_name: paintplus + ports: + - "${PORT:-3080}:8000" + volumes: + # Persistent project data + - ./data:/app/data + # HuggingFace model cache — bind mount so models can be pre-downloaded on the host. + # If container DNS is blocked, run ./prefetch-models.sh on the host first — + # it downloads BEN2/BiRefNet-HR (and optionally SDXL with --sdxl) straight + # into this directory, and the container picks them up on next start. + # To free disk space: rm -rf ./data/hf_cache + - ./data/hf_cache:/root/.cache/huggingface + # Scripts (for exec access) + - ./scripts:/scripts + environment: + # ── Local GPU (default for this compose) ──────────────────────────────── + - AI_PROVIDER=${AI_PROVIDER:-local_gpu} + - AUTO_DOWNLOAD_MODELS=${AUTO_DOWNLOAD_MODELS:-true} + + # ── Per-operation overrides (optional) ────────────────────────────────── + # Leave blank to use AI_PROVIDER for all operations. + # Example: use InvokeAI for inpaint, local GPU for everything else: + # AI_PROVIDER_INPAINT=invokeai + - AI_PROVIDER_INPAINT=${AI_PROVIDER_INPAINT:-} + - AI_PROVIDER_TXT2IMG=${AI_PROVIDER_TXT2IMG:-} + - AI_PROVIDER_IMG2IMG=${AI_PROVIDER_IMG2IMG:-} + - AI_PROVIDER_OUTPAINT=${AI_PROVIDER_OUTPAINT:-} + + # ── Remote/cloud providers (all optional) ──────────────────────────────── + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - OPENAI_MODEL=${OPENAI_MODEL:-dall-e-3} + - REPLICATE_API_KEY=${REPLICATE_API_KEY:-} + - STABILITY_API_KEY=${STABILITY_API_KEY:-} + + # ── InvokeAI / ComfyUI (running on another machine or container) ──────── + - INVOKEAI_URL=${INVOKEAI_URL:-} + - INVOKEAI_DEFAULT_MODEL=${INVOKEAI_DEFAULT_MODEL:-flux-dev} + - COMFYUI_URL=${COMFYUI_URL:-} + - COMFYUI_DEFAULT_MODEL=${COMFYUI_DEFAULT_MODEL:-v1-5-pruned-emaonly.ckpt} + + # ── HuggingFace model overrides (optional) ─────────────────────────────── + # Override the auto-selected model for any operation: + # HF_MODEL_INPAINT=your-org/your-model + - HF_MODEL_INPAINT=${HF_MODEL_INPAINT:-} + - HF_MODEL_TXT2IMG=${HF_MODEL_TXT2IMG:-} + - HF_MODEL_IMG2IMG=${HF_MODEL_IMG2IMG:-} + - HF_TOKEN=${HF_TOKEN:-} + + # ── App settings ───────────────────────────────────────────────────────── + - DATABASE_URL=sqlite:///./data/ai_photo_edit.db + - SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production} + - CORS_ORIGINS=* + - AUTO_DOWNLOAD_SAM=${AUTO_DOWNLOAD_SAM:-true} + - AUTO_DOWNLOAD_U2NET=${AUTO_DOWNLOAD_U2NET:-true} + - BG_REMOVAL_MODEL=${BG_REMOVAL_MODEL:-ben2} + + # ── NVIDIA GPU passthrough ──────────────────────────────────────────────── + # Requires nvidia-container-toolkit; see prerequisites at top of this file. + # For older nvidia-docker2 setups, replace this block with: + # runtime: nvidia + # environment: + # - NVIDIA_VISIBLE_DEVICES=all + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + # DNS: try host resolver first (works on most networks including corporate/VPN), + # fall back to Cloudflare then Google public resolvers. + # If all three fail (Errno -3), your firewall is blocking port 53 UDP from Docker. + # Fix on the host: sudo iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT + dns: + - 1.1.1.1 + - 8.8.8.8 + - 8.8.4.4 + + restart: unless-stopped diff --git a/paintplus/docker-compose.yml b/paintplus/docker-compose.yml new file mode 100644 index 0000000..238df41 --- /dev/null +++ b/paintplus/docker-compose.yml @@ -0,0 +1,27 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: paintplus + ports: + - "3080:8000" + volumes: + - ./data:/app/data + - ./scripts:/scripts + environment: + - DATABASE_URL=sqlite:///./data/ai_photo_edit.db + - SECRET_KEY=${SECRET_KEY:-change-this-secret-key-in-production} + - AI_PROVIDER=${AI_PROVIDER:-mock} + - OPENAI_API_KEY=${OPENAI_API_KEY:-} + - STABILITY_API_KEY=${STABILITY_API_KEY:-} + - REPLICATE_API_KEY=${REPLICATE_API_KEY:-} + - CORS_ORIGINS=* + # DNS servers for reliable external API access (Replicate, etc.) + dns: + - 8.8.8.8 + - 8.8.4.4 + restart: unless-stopped + +volumes: + data: diff --git a/paintplus/docs/AI_PROVIDER_COMPARISON.md b/paintplus/docs/AI_PROVIDER_COMPARISON.md new file mode 100644 index 0000000..c8551ca --- /dev/null +++ b/paintplus/docs/AI_PROVIDER_COMPARISON.md @@ -0,0 +1,261 @@ +# AI Provider Cost & Quality Comparison + +## Provider Options for Inpainting/Image Editing + +### 1. OpenAI DALL-E 2 ❌ (Not Recommended) +**Current implementation uses this when `AI_PROVIDER=openai`** + +**Pricing:** +- $0.020 per image (1024x1024) +- $0.018 per image (512x512) + +**Quality:** ⭐⭐ (2/5) +- Old model (2022) +- Significantly lower quality than DALL-E 3 +- Cannot match ChatGPT web interface +- Often produces artifacts + +**Pros:** +- Simple API +- Fast responses + +**Cons:** +- Poor quality by modern standards +- Limited to 1024x1024 max +- No access to DALL-E 3 inpainting + +**Verdict:** ❌ Don't use unless you need the cheapest option and quality doesn't matter + +--- + +### 2. Stability AI (Stable Diffusion XL) ✅ (Good Choice) +**Direct API to Stability AI** + +**Pricing:** +- Credits-based system +- ~$0.010 per image (512x512) +- ~$0.040 per image (1024x1024) +- Must buy credit packs ($10 minimum = 1000 credits) + +**Quality:** ⭐⭐⭐⭐ (4/5) +- Excellent inpainting quality +- Good at following prompts +- Natural-looking results +- Well-suited for photo editing + +**Pros:** +- Built specifically for inpainting +- Good quality-to-cost ratio +- Reliable API +- Fast generation (15-30 seconds) + +**Cons:** +- Requires credit purchase upfront +- Limited to SDXL models +- Less flexible than Replicate + +**Verdict:** ✅ Best balance of quality and cost for direct API + +--- + +### 3. Replicate ⭐ (Most Flexible) +**API marketplace with multiple models** + +**Pricing:** Pay-per-second of GPU time +- SDXL Inpainting: ~$0.0023/sec (~$0.01-0.03 per image) +- Kandinsky 2.2: ~$0.0023/sec (~$0.01-0.02 per image) +- LaMa (removal): ~$0.0005/sec (~$0.002 per image) +- Varies by model and parameters + +**Quality:** ⭐⭐⭐⭐⭐ (5/5 - depends on model choice) +- Access to multiple models +- Can choose best model for each use case +- Community models available +- Often better than Stability direct + +**Pros:** +- Multiple models to choose from +- Pay only for what you use (no minimums) +- Can use free models +- New models added regularly +- Fine-tuned models available + +**Cons:** +- More complex to implement +- Pricing varies by model +- Need to understand different models + +**Best Models on Replicate:** +- **SDXL Inpainting**: General purpose, excellent quality +- **LaMa**: Best for object removal +- **Kandinsky 2.2**: Good alternative to SDXL +- **ControlNet Inpainting**: More control over results + +**Verdict:** ⭐ Most flexible, best value if you implement multiple models + +--- + +### 4. Local Models (Self-Hosted) 💰 (Best Quality, No Per-Use Cost) + +**Pricing:** +- $0 per image after setup +- Requires GPU (RTX 3060 12GB minimum, RTX 4090 ideal) +- Cloud GPU: $0.30-$1.00/hour (RunPod, Vast.ai) + +**Quality:** ⭐⭐⭐⭐⭐ (5/5) +- Best possible quality +- Full control over model selection +- Can use latest open-source models +- No API limitations + +**Setup Costs:** +- GPU hardware: $300-$2000 +- OR Cloud GPU rental: $0.30-$1.00/hour + +**Pros:** +- Unlimited usage once set up +- Best quality available +- Complete privacy +- No API rate limits +- Can fine-tune models + +**Cons:** +- Requires GPU or cloud rental +- More complex setup +- Slower than cloud APIs (if CPU only) + +**Verdict:** 💰 Best long-term if you have GPU or high volume + +--- + +## Stability AI vs Replicate: What's the Difference? + +### Stability AI (stability.ai) +**What it is:** +- The company that created Stable Diffusion +- Direct API to their hosted models +- Official source + +**Business Model:** +- Buy credits upfront +- Credits expire after 3 months +- Official support +- Guaranteed uptime SLA + +**Models Available:** +- Stable Diffusion XL +- Stable Diffusion 1.5 +- Their official models only + +--- + +### Replicate (replicate.com) +**What it is:** +- Marketplace/platform for running ML models +- Hosts models from many sources +- Pay-per-use GPU time + +**Business Model:** +- Pay only for GPU seconds used +- No upfront purchase +- No credits that expire +- $0.01 minimum charge per prediction + +**Models Available:** +- Stability AI's models (SDXL, SD 1.5) +- Community models +- Fine-tuned variants +- Specialized models (LaMa, ControlNet, etc.) +- 100+ image generation models + +**Think of it like:** +- **Stability AI** = Buying directly from Apple +- **Replicate** = App Store with many developers + +--- + +## Cost Comparison Examples + +### Scenario: 100 edits per month + +| Provider | Cost per Image | Monthly Cost | Quality | +|----------|---------------|--------------|---------| +| DALL-E 2 | $0.020 | $2.00 | ⭐⭐ Poor | +| Stability AI | $0.040 | $4.00 | ⭐⭐⭐⭐ Good | +| Replicate (SDXL) | $0.025 | $2.50 | ⭐⭐⭐⭐⭐ Excellent | +| Replicate (LaMa) | $0.002 | $0.20 | ⭐⭐⭐⭐ Good for removal | +| Local GPU | $0.00 | $0.00* | ⭐⭐⭐⭐⭐ Best | + +*Requires $500+ GPU or $0.30-1.00/hr cloud GPU + +### Scenario: 1000 edits per month (Heavy use) + +| Provider | Monthly Cost | Notes | +|----------|--------------|-------| +| DALL-E 2 | $20.00 | Not worth it | +| Stability AI | $40.00 | Need $10-40 credit refills | +| Replicate (SDXL) | $25.00 | Pay as you go | +| Local GPU | $0.00 | GPU pays for itself after ~50K images | +| Cloud GPU (RunPod) | $20-60 | Depends on uptime needed | + +--- + +## Quality Rankings for Inpainting + +**Best to Worst:** + +1. **Local SDXL Inpainting** ⭐⭐⭐⭐⭐ (self-hosted) +2. **Replicate SDXL Inpainting** ⭐⭐⭐⭐⭐ +3. **Stability AI SDXL** ⭐⭐⭐⭐ +4. **Replicate LaMa** ⭐⭐⭐⭐ (for removal only) +5. **DALL-E 2** ⭐⭐ (outdated) + +--- + +## Recommendation by Use Case + +### Best for Testing/Development: Mock Provider +- Cost: $0 +- Quality: N/A (returns original) +- Use when: Building/testing UI + +### Best for Low Volume (< 100/month): Replicate +- Cost: ~$2.50/month +- Quality: ⭐⭐⭐⭐⭐ +- No minimum purchase +- Multiple model options + +### Best for Medium Volume (100-1000/month): Replicate or Stability AI +- Replicate: ~$25/month, more flexibility +- Stability AI: ~$40/month, simpler API + +### Best for High Volume (1000+/month): Local GPU or Cloud GPU +- Unlimited usage +- Best quality +- Full control + +### Best Overall Value: Replicate +- No minimum purchase +- Pay only for what you use +- Best model selection +- Easy to try multiple models + +--- + +## My Recommendation + +Start with **Replicate** because: + +1. ✅ No upfront cost (vs Stability's $10 minimum) +2. ✅ Better quality than DALL-E 2 +3. ✅ Can try multiple models to find what works +4. ✅ Cheapest per-image for low-medium volume +5. ✅ Can switch to Stability AI later if needed + +**Next Steps:** +- I can add Replicate support (30 min of work) +- Test with SDXL Inpainting first +- Try LaMa for object removal +- Fall back to Stability if needed + +Would you like me to add Replicate support? diff --git a/paintplus/docs/MODEL_SELECTION_GUIDE.md b/paintplus/docs/MODEL_SELECTION_GUIDE.md new file mode 100644 index 0000000..4e39646 --- /dev/null +++ b/paintplus/docs/MODEL_SELECTION_GUIDE.md @@ -0,0 +1,369 @@ +# Model Selection Guide for Body Parts and Editing Tasks + +## Quick Reference: Best Models by Use Case + +### Human Features (Faces, Hands, Bodies) + +**Best Choice: `realistic-vision` (Replicate)** + +```env +AI_PROVIDER=replicate +REPLICATE_API_KEY=your-key +REPLICATE_MODEL=realistic-vision +``` + +**Why:** Trained specifically on human anatomy and realistic photos. Handles difficult features like: +- ✅ Hands (notoriously hard for AI) +- ✅ Faces and facial features +- ✅ Skin textures and tones +- ✅ Body proportions +- ✅ Portraits + +**Examples:** +- "Fix the hand position" +- "Remove red eye" +- "Smooth skin blemishes" +- "Adjust facial expression" +- "Fix fingers" + +**Cost:** ~$0.020/image +**Quality:** ⭐⭐⭐⭐⭐ + +--- + +### Object Removal + +**Best Choice: `lama` (Replicate)** + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=lama +``` + +**Why:** Specifically designed for inpainting and object removal. Excellent at: +- ✅ Removing objects cleanly +- ✅ Filling in backgrounds naturally +- ✅ Maintaining surrounding context +- ✅ Fast and cheap + +**Examples:** +- "Remove the person" +- "Delete the watermark" +- "Erase the object" +- "Clean up the background" + +**Cost:** ~$0.002/image (cheapest!) +**Quality:** ⭐⭐⭐⭐ + +--- + +### General Purpose Editing + +**Best Choice: `sdxl-inpaint` (Replicate or Stability AI)** + +```env +# Option 1: Replicate +AI_PROVIDER=replicate +REPLICATE_MODEL=sdxl-inpaint + +# Option 2: Stability AI Direct +AI_PROVIDER=stability +STABILITY_MODEL=sdxl +``` + +**Why:** SDXL (Stable Diffusion XL) is the best all-around model for: +- ✅ Landscapes and scenery +- ✅ Objects and textures +- ✅ Creative edits +- ✅ Style changes +- ✅ Adding elements + +**Examples:** +- "Change sky to sunset" +- "Add flowers" +- "Make it autumn" +- "Replace with grass" + +**Cost:** +- Replicate: ~$0.025/image +- Stability AI: ~$0.040/image + +**Quality:** ⭐⭐⭐⭐⭐ + +--- + +## Detailed Comparison by Body Part + +### Hands ✋ + +**Challenge:** Hands are the hardest thing for AI to generate correctly. Common issues: +- Wrong number of fingers +- Unnatural finger positions +- Distorted proportions +- Weird joints + +**Best Models (in order):** + +1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐ + - Best overall for hands + - Understands hand anatomy + - Cost: ~$0.020/image + +2. **SDXL Inpainting** (Replicate/Stability) - ⭐⭐⭐ + - Decent but less consistent + - Cost: ~$0.025-0.040/image + +3. **DALL-E 2** (OpenAI) - ⭐⭐ + - Often struggles with hands + - Not recommended + +**Tips for Better Hand Edits:** +- Use detailed prompts: "realistic human hand with five fingers" +- Add negative prompts if provider supports: "deformed, extra fingers, missing fingers" +- Use Mode B (full image context) for better results +- Consider editing in multiple passes if needed + +--- + +### Faces 😊 + +**Challenge:** Faces need to look natural and maintain proper proportions + +**Best Models:** + +1. **Realistic Vision** (Replicate) - ⭐⭐⭐⭐⭐ + - Excellent for facial features + - Natural skin textures + - Good expression handling + +2. **SDXL Inpainting** - ⭐⭐⭐⭐ + - Good for general facial edits + - Better for style than realism + +**Use Cases:** +- Remove blemishes +- Fix red eye +- Adjust expressions +- Change hair +- Smooth wrinkles + +--- + +### Full Body / Torso 🧍 + +**Best Model:** Realistic Vision + +**Why:** Maintains body proportions and realistic anatomy + +**Examples:** +- "Fix the clothing wrinkles" +- "Change shirt color to blue" +- "Remove the stain" + +--- + +### Hearts ♥️ (Decorative Elements) + +**Best Model:** SDXL Inpainting + +**Why:** Great for creative and decorative elements + +**Examples:** +- "Add heart shape" +- "Draw a heart pattern" +- "Replace with hearts" + +--- + +## Auto-Selection Feature + +The system automatically selects the best model based on your prompt: + +### Keywords that trigger `realistic-vision`: +- hand, hands, finger, fingers +- face, facial, portrait, eyes, nose, mouth +- body, person, human, skin, people +- realistic, photo, photograph + +### Keywords that trigger `lama` (removal): +- remove, delete, erase, cleanup +- disappear, hide, clear + +### Default: `sdxl-inpaint` +- Everything else uses SDXL for best general quality + +**Example Auto-Selection:** +```python +# User prompt: "Fix the hand" → auto-selects realistic-vision +# User prompt: "Remove the person" → auto-selects lama +# User prompt: "Change to sunset" → auto-selects sdxl-inpaint +``` + +--- + +## Manual Model Override + +### Via Environment Variable +Set default model in `.env`: +```env +REPLICATE_MODEL=realistic-vision +``` + +### Via API Request +Override per-edit in the API: +```json +{ + "prompt": "Fix the hand", + "ai_provider": "replicate", + "ai_model": "realistic-vision", + "mode": "A", + ... +} +``` + +### Via Frontend (Future Feature) +Model selector dropdown in the UI. + +--- + +## Cost Optimization Strategies + +### For Low-Volume Users (< 100 edits/month) +**Recommendation:** Use Replicate with auto-selection + +**Why:** +- No minimum purchase +- Pay only for what you use +- Auto-selects cheapest appropriate model + +**Estimated Cost:** $1-3/month + +--- + +### For Medium-Volume Users (100-1000 edits/month) +**Recommendation:** Replicate or Stability AI + +**Strategy:** +- Use `lama` for removals ($0.002/image) +- Use `realistic-vision` for humans ($0.020/image) +- Use `sdxl-inpaint` for general ($0.025/image) + +**Estimated Cost:** $10-30/month + +--- + +### For High-Volume Users (1000+ edits/month) +**Recommendation:** Consider local GPU or cloud GPU + +**Why:** +- No per-image cost +- Best quality control +- Privacy + +**Setup:** +- Local: RTX 3060+ GPU ($300-2000 one-time) +- Cloud: RunPod/Vast.ai ($0.30-1.00/hour) + +--- + +## Quality Comparison Table + +| Use Case | DALL-E 2 | Stability SDXL | Replicate SDXL | Replicate Realistic | Replicate LaMa | +|----------|----------|----------------|----------------|---------------------|----------------| +| Hands | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Faces | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Bodies | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | +| Objects | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A | +| Landscapes | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | N/A | +| Removal | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| Creative | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ | + +--- + +## Advanced Tips + +### For Difficult Hands +1. **Use Mode B** - Provides full image context +2. **Be specific** - "realistic five-fingered hand in natural pose" +3. **Multiple passes** - Fix gross errors first, then refine +4. **Reference images** - Mode B helps AI understand the pose + +### For Facial Features +1. **High feather value** - 10-15px for smooth blending +2. **Small selections** - Target specific features +3. **Natural lighting** - Mention lighting in prompt + +### For Body Parts +1. **Maintain proportions** - Use Mode B for body context +2. **Clothing context** - Include clothing description in prompt +3. **Skin tone consistency** - Mention skin tone if needed + +--- + +## Troubleshooting Common Issues + +### "Hands have too many fingers" +- **Solution:** Switch to `realistic-vision` model +- **Prompt:** "realistic human hand with exactly five fingers" +- **Try:** Multiple generations, pick best result + +### "Face looks unnatural" +- **Solution:** Use `realistic-vision` model +- **Increase:** Feather value to 15-20px +- **Try:** Mode B for better context + +### "Removal leaves artifacts" +- **Solution:** Use `lama` model (designed for removal) +- **Alternative:** SDXL with prompt "clean background" + +### "Colors don't match" +- **Increase:** Feather value to 20-30px +- **Try:** Mode B for better color context +- **Prompt:** Include color description + +--- + +## Quick Start Examples + +### Example 1: Fix a Hand +```json +{ + "prompt": "realistic human hand with five fingers, natural pose", + "ai_provider": "replicate", + "ai_model": "realistic-vision", + "mode": "B", + "feather_px": 10 +} +``` + +### Example 2: Remove an Object +```json +{ + "prompt": "remove the object, clean background", + "ai_provider": "replicate", + "ai_model": "lama", + "mode": "A", + "feather_px": 5 +} +``` + +### Example 3: Change Sky +```json +{ + "prompt": "sunset sky with orange and pink clouds", + "ai_provider": "replicate", + "ai_model": "sdxl-inpaint", + "mode": "A", + "feather_px": 15 +} +``` + +--- + +## Summary + +**For Body Parts:** Use `realistic-vision` (Replicate) +**For Removal:** Use `lama` (Replicate) +**For Everything Else:** Use `sdxl-inpaint` (Replicate or Stability) + +**Let the auto-selection do its job** - it's optimized for these use cases! diff --git a/paintplus/docs/PUBLIC_DOMAIN_EYE_SOURCES.md b/paintplus/docs/PUBLIC_DOMAIN_EYE_SOURCES.md new file mode 100644 index 0000000..f719c3e --- /dev/null +++ b/paintplus/docs/PUBLIC_DOMAIN_EYE_SOURCES.md @@ -0,0 +1,300 @@ +# Public Domain Carved Eye Sources + +Where to find high-quality images of carved eyes from classical sculptures (all public domain). + +--- + +## Best Museums with Public Domain Images + +### 1. Metropolitan Museum of Art (CC0 Public Domain) + +**Website:** https://www.metmuseum.org/art/collection + +**Search tips:** +- Search: "greek statue marble head" +- Search: "roman portrait bust" +- Filter: "Public Domain" only +- Download: Click "Download" for high-resolution + +**Great examples:** +- Greek Kouros heads (Archaic period) +- Roman portrait busts +- Hellenistic marble sculptures + +**Direct collections:** +- Greek & Roman Art: https://www.metmuseum.org/art/collection/search#!?department=13 +- Filter by "Images" → "Public Domain" + +--- + +### 2. Smithsonian Open Access (CC0) + +**Website:** https://www.si.edu/openaccess + +**Features:** +- 3 million+ images +- All CC0 (no copyright restrictions) +- High-resolution downloads + +**Search:** +- "roman marble head" +- "greek sculpture eyes" +- "classical portrait bust" + +**API available:** https://api.si.edu/openaccess/api/v1.0/ + +--- + +### 3. Getty Museum (Open Content) + +**Website:** https://www.getty.edu/art/collection/ + +**Search tips:** +- Filter: "Open Content Program" +- Greek and Roman antiquities +- High-resolution IIIF images + +**Great for:** +- Archaic Greek sculptures +- Classical period heads +- Detailed close-ups + +--- + +### 4. Rijksmuseum (Public Domain) + +**Website:** https://www.rijksmuseum.nl/en/rijksstudio + +**Features:** +- Rijksstudio (free download tool) +- High-resolution images +- Classical sculpture collection + +--- + +### 5. British Museum (CC BY-NC-SA 4.0) + +**Website:** https://www.britishmuseum.org/collection + +**Note:** Some restrictions, but many images free for non-commercial use + +**Great for:** +- Egyptian carved eyes +- Greek marble heads +- Roman portraits + +--- + +### 6. Louvre Collections + +**Website:** https://collections.louvre.fr/en/ + +**Search:** "sculpture greek head" or "sculpture roman portrait" + +**Note:** Check individual image licenses + +--- + +## How to Find the Perfect Eyes + +### Search Strategy + +1. **Search for heads/busts, not full statues:** + - "greek marble head" + - "roman portrait bust" + - "classical sculpture face" + +2. **Specific periods:** + - "archaic greek kouros" (serene, stylized) + - "classical greek sculpture" (idealized, peaceful) + - "hellenistic sculpture" (emotional, dramatic) + - "roman portrait" (realistic, wise) + +3. **Look for close-ups:** + - Museums often provide detail shots + - Check "zoom" or "IIIF viewer" options + +--- + +## Recommended Starting Collection + +### Serene/Peaceful Eyes + +**Greek Classical Period (450-400 BCE):** +- Doryphoros (Spear Bearer) type +- Athena heads +- Apollo statues +- Smooth, idealized features +- Almond-shaped eyes +- Minimal lid detail + +**Best sources:** Met Museum, Getty + +--- + +### Fierce/Intense Eyes + +**Hellenistic Period (323-31 BCE):** +- Alexander the Great portraits +- Dying Gaul +- Laocoon group +- Dramatic expressions +- Deep-set eyes +- Strong brow ridges + +**Best sources:** Smithsonian, British Museum + +--- + +### Wise/Aged Eyes + +**Roman Republican Period:** +- Senator portraits +- Veristic portraits +- Realistic aging details +- Detailed wrinkles +- Saggy eyelids +- Life-like features + +**Best sources:** Met Museum, Getty + +--- + +### Stylized/Archaic Eyes + +**Greek Archaic Period (700-480 BCE):** +- Kouros statues +- Kore statues +- Almond-shaped +- Simplified forms +- "Archaic smile" +- Clean, simple carving + +**Best sources:** Getty, Met Museum + +--- + +## How to Download and Crop + +### Step 1: Find the Statue + +Example: Met Museum +1. Go to https://www.metmuseum.org/art/collection +2. Search: "roman portrait marble" +3. Filter: Public Domain only +4. Click on a good example + +### Step 2: Download High-Res + +1. Click "Download" button +2. Choose largest size (usually 4000px+) +3. Save to your computer + +### Step 3: Crop the Eyes + +Use any image editor (Photoshop, GIMP, etc.): + +1. Open the full statue image +2. Zoom in on one eye +3. Crop just the eye area: + - Include: eyeball, eyelids, tear duct, socket + - Leave some surrounding area for context + - Square or slightly rectangular crop + +4. Save as PNG: + - `greek_serene_left.png` + - `roman_fierce_right.png` + - etc. + +5. Repeat for other eye (if different) + +### Step 4: Organize + +Place cropped eyes in: +``` +./backend/scripts/seed_data/eyes/ +├── greek_serene_left.png +├── greek_serene_right.png +├── roman_fierce_left.png +├── roman_fierce_right.png +├── greek_peaceful_left.png +└── ... +``` + +### Step 5: Run Seed Script + +```bash +cd backend +python scripts/seed_eye_catalog.py +``` + +--- + +## Recommended Starting Collection (10 Eyes) + +To start, get these 10 eyes: + +### Greek Classical (Serene) +1. Left eye - Greek marble head +2. Right eye - Greek marble head + +### Greek Archaic (Stylized/Peaceful) +3. Left eye - Kouros statue +4. Right eye - Kouros statue + +### Hellenistic (Fierce/Dramatic) +5. Left eye - Alexander portrait +6. Right eye - Alexander portrait + +### Roman Republican (Wise/Aged) +7. Left eye - Roman senator bust +8. Right eye - Roman senator bust + +### Roman Imperial (Powerful) +9. Left eye - Emperor portrait +10. Right eye - Emperor portrait + +This gives you 5 emotional ranges × 2 eyes = 10 eyes to start! + +--- + +## Quick Links + +- **Met Museum Collection:** https://www.metmuseum.org/art/collection/search#!?department=13&showOnly=openAccess +- **Smithsonian Open Access:** https://www.si.edu/openaccess +- **Getty Open Content:** https://www.getty.edu/about/whatwedo/opencontent.html +- **Rijksmuseum API:** https://data.rijksmuseum.nl/object-metadata/api/ + +--- + +## Legal Notes + +- **CC0/Public Domain:** Use freely for any purpose +- **CC BY:** Must credit the source +- **CC BY-NC:** Non-commercial use only +- **Always check** individual image licenses + +For commercial carving business, stick to **CC0** or **Public Domain** images. + +--- + +## Tips for Best Results + +1. **High resolution:** Download largest size available (2000px+ minimum) +2. **Good lighting:** Look for evenly lit photographs +3. **Straight-on angle:** Avoid extreme angles +4. **Clear detail:** Can you see the eyelid lines clearly? +5. **Minimal damage:** Choose well-preserved sculptures + +--- + +## Next Steps + +1. Browse the museums above +2. Download 10-20 good eye examples +3. Crop them in an image editor +4. Place in `backend/scripts/seed_data/eyes/` +5. Run the seed script +6. Your catalog is ready! + +**You'll have a library of proven carved eyes from master sculptors spanning 2000+ years!** diff --git a/paintplus/docs/QUICK_START.md b/paintplus/docs/QUICK_START.md new file mode 100644 index 0000000..5e5b814 --- /dev/null +++ b/paintplus/docs/QUICK_START.md @@ -0,0 +1,350 @@ +# Quick Start Guide + +## How to Choose the Right AI Model + +### For Body Parts (Hands, Faces, Bodies) + +Use **Replicate with `realistic-vision`** model: + +```env +AI_PROVIDER=replicate +REPLICATE_API_KEY=your-key-here +REPLICATE_MODEL=realistic-vision +``` + +**Why:** This model is specifically trained on human anatomy and handles difficult features like: +- ✅ Hands (even complex finger positions) +- ✅ Faces and expressions +- ✅ Skin textures +- ✅ Body proportions + +**Cost:** ~$0.020/image + +### For Removing Objects + +Use **Replicate with `lama`** model: + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=lama +``` + +**Why:** Designed specifically for inpainting and removal +**Cost:** ~$0.002/image (cheapest!) + +### For General Edits (Landscapes, Objects, Creative) + +Use **Replicate with `sdxl-inpaint`** model (default): + +```env +AI_PROVIDER=replicate +REPLICATE_MODEL=sdxl-inpaint +``` + +**Cost:** ~$0.025/image + +--- + +## Auto-Model Selection + +The system automatically picks the best model based on your prompt: + +| Your Prompt | Auto-Selected Model | Why | +|-------------|-------------------|-----| +| "Fix the hand" | realistic-vision | Detects "hand" keyword | +| "Remove person" | lama | Detects "remove" keyword | +| "Change sky to sunset" | sdxl-inpaint | General purpose default | + +**You don't need to manually specify models** - the auto-selection is optimized for quality and cost! + +--- + +## Patch Library: Save and Reuse Parts + +### What is the Patch Library? + +A library where you can save image patches (regions) and reuse them across different images. + +**Use Cases:** +- Save a well-generated hand to reuse later +- Save a perfect face for multiple photos +- Build a collection of good body parts +- Save textures, objects, or backgrounds +- Reuse AI-generated elements that came out great + +### How to Save a Patch + +#### Option 1: Save AI-Generated Result + +After an AI edit completes: + +```bash +POST /patches/ +{ + "name": "Perfect Hand", + "description": "Well-formed left hand, palm up", + "source_type": "ai_generated", + "source_edit_id": 123, + "category": "hand", + "tags": "left, palm, realistic" +} +``` + +This saves the AI-generated output (`patch_out.png`) to your library. + +#### Option 2: Save Manual Selection + +Select any region from your current image: + +```bash +POST /patches/ +{ + "name": "Good Face", + "description": "Frontal face with good lighting", + "source_type": "manual_selection", + "source_project_id": 456, + "bbox": {"x": 100, "y": 100, "width": 200, "height": 200}, + "category": "face", + "tags": "front, smile, female" +} +``` + +This saves whatever is currently in that region of your image. + +#### Option 3: Import from File + +Upload an external image: + +```bash +POST /patches/ +FormData: + name: "Downloaded Hand" + source_type: "imported" + file: [uploaded PNG file] + category: "hand" +``` + +### How to Apply a Saved Patch + +```bash +POST /patches/apply +{ + "project_id": 789, + "patch_id": 123, + "bbox": {"x": 300, "y": 400, "width": 200, "height": 200}, + "feather_px": 10 +} +``` + +This places the saved patch at the specified location in your image. + +### Browse Your Patch Library + +```bash +# List all patches +GET /patches/ + +# Filter by category +GET /patches/?category=hand + +# Filter by tags +GET /patches/?tags=realistic + +# Get specific patch +GET /patches/123 + +# Get patch image +GET /patches/123/image + +# Get patch thumbnail +GET /patches/123/image?thumbnail=true +``` + +### Organize Your Patches + +**Categories:** +- `hand` - Hand images +- `face` - Facial features +- `body` - Body parts +- `object` - Objects and items +- `texture` - Textures and patterns +- `background` - Backgrounds and scenery + +**Tags:** Comma-separated keywords for searching +- "left, palm, realistic" +- "front, smile, female" +- "five fingers, open hand" + +--- + +## Complete Workflow Example + +### Scenario: Fix hands in a portrait photo + +**Step 1: Create project and upload image** +```bash +POST /projects/ {"name": "Portrait Edit"} +POST /projects/1/upload [upload photo] +``` + +**Step 2: Try to fix the hand with AI** +```bash +POST /edits/projects/1/fix +{ + "prompt": "realistic human hand with five fingers, natural pose", + "mode": "B", # Use full image for context + "selection_type": "rectangle", + "bbox": {"x": 200, "y": 300, "width": 150, "height": 200}, + "feather_px": 10 +} +``` + +The system auto-selects `realistic-vision` model because prompt mentions "hand". + +**Step 3: If result is good, save it for later** +```bash +POST /patches/ +{ + "name": "Good Left Hand", + "source_type": "ai_generated", + "source_edit_id": 1, + "category": "hand", + "tags": "left, natural, realistic, five fingers" +} +``` + +**Step 4: Use saved hand on another photo** +```bash +# On a different project +POST /patches/apply +{ + "project_id": 2, + "patch_id": 1, + "bbox": {"x": 150, "y": 250, "width": 150, "height": 200}, + "feather_px": 15 +} +``` + +--- + +## Cost Comparison + +### Example: Fixing 10 hands in different photos + +**Option A: Generate each hand with AI** +- 10 edits × $0.020 = **$0.20** + +**Option B: Generate one good hand, save it, reuse it** +- 1 AI generation: $0.020 +- 9 patch applications: $0.00 (no AI cost) +- **Total: $0.020** (90% savings!) + +### When to Use Saved Patches vs AI + +**Use Saved Patches When:** +- You have a perfect result you want to reuse +- Same angle/lighting/style needed +- Want to maintain consistency across images +- Want to avoid AI generation costs + +**Use AI Generation When:** +- Need unique/different result each time +- Different angle or perspective needed +- Want variation and creativity +- Patch doesn't fit the context + +--- + +## Pro Tips + +### Building a Good Patch Library + +1. **Save your best AI results** - When AI generates something great, save it immediately +2. **Organize with categories** - Use consistent categories for easy finding +3. **Tag descriptively** - Include orientation (left/right), pose, lighting, etc. +4. **Create variations** - Save multiple versions of common needs (left hand, right hand, etc.) +5. **Build gradually** - Your library becomes more valuable over time + +### Maximizing Quality + +1. **For hands:** Always use `realistic-vision` model or save good results +2. **For faces:** Use Mode B (full image context) for better matching +3. **Use high feather values** (15-20px) when applying saved patches +4. **Test positioning** before finalizing - patches work best when lighting/angle matches + +### Saving Money + +1. **Build a patch library** of common needs +2. **Use `lama` for removals** instead of expensive models +3. **Let auto-selection work** - it picks the cheapest appropriate model +4. **Reuse successful patches** instead of regenerating + +--- + +## API Quick Reference + +```bash +# List available patches +GET /patches/ + +# Get patch details +GET /patches/{id} + +# Get patch image +GET /patches/{id}/image +GET /patches/{id}/image?thumbnail=true + +# Create patch from AI edit +POST /patches/ +{ + "name": "My Patch", + "source_type": "ai_generated", + "source_edit_id": 123, + "category": "hand" +} + +# Create patch from manual selection +POST /patches/ +{ + "name": "My Patch", + "source_type": "manual_selection", + "source_project_id": 456, + "bbox": {"x": 100, "y": 100, "width": 200, "height": 200} +} + +# Apply saved patch +POST /patches/apply +{ + "project_id": 789, + "patch_id": 123, + "bbox": {"x": 300, "y": 400, "width": 200, "height": 200}, + "feather_px": 10 +} + +# Delete patch +DELETE /patches/{id} + +# Update patch metadata +PUT /patches/{id} +{ + "name": "Updated Name", + "tags": "new, tags", + "category": "hand" +} +``` + +--- + +## Summary + +✅ **For hands/faces/bodies:** Use `realistic-vision` model +✅ **For removal:** Use `lama` model +✅ **For general edits:** Use `sdxl-inpaint` (default) +✅ **Auto-selection works great** - just write natural prompts +✅ **Save good AI results** to patch library for reuse +✅ **Save manual selections** from any image +✅ **Reuse patches across images** to save money and maintain consistency + +**You now have the best of both worlds:** +- AI generation when you need something new +- Saved patches when you need consistency or want to save money diff --git a/paintplus/frontend/.babelrc b/paintplus/frontend/.babelrc new file mode 100644 index 0000000..2b31862 --- /dev/null +++ b/paintplus/frontend/.babelrc @@ -0,0 +1,8 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": [ + ["@babel/plugin-transform-runtime", { + "regenerator": true + }] + ] +} diff --git a/paintplus/frontend/.gitignore b/paintplus/frontend/.gitignore new file mode 100644 index 0000000..9df4bb1 --- /dev/null +++ b/paintplus/frontend/.gitignore @@ -0,0 +1,17 @@ +# OS generated files # +.DS_Store +.DS_Store? +._* +.Trashes +ehthumbs.db +Thumbs.db +nbproject/ +.idea/ +.git/ +.vscode +/.project +*.log + +/node_modules/ +*.js.ignore + diff --git a/paintplus/frontend/Dockerfile b/paintplus/frontend/Dockerfile new file mode 100644 index 0000000..b462921 --- /dev/null +++ b/paintplus/frontend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine as build + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json ./ + +# Install dependencies +RUN npm ci + +# Copy source +COPY . . + +# Build webpack bundle +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy all static files needed by miniPaint +COPY --from=build /app/index.html /usr/share/nginx/html/ +COPY --from=build /app/dist /usr/share/nginx/html/dist +COPY --from=build /app/images /usr/share/nginx/html/images +COPY --from=build /app/src/css /usr/share/nginx/html/src/css + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/paintplus/frontend/Dockerfile.dev b/paintplus/frontend/Dockerfile.dev new file mode 100644 index 0000000..7b5b1c2 --- /dev/null +++ b/paintplus/frontend/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:20-alpine + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source +COPY . . + +EXPOSE 5173 + +# Run dev server +CMD ["npm", "run", "dev", "--", "--host"] diff --git a/paintplus/frontend/MIT-LICENSE.txt b/paintplus/frontend/MIT-LICENSE.txt new file mode 100644 index 0000000..760e232 --- /dev/null +++ b/paintplus/frontend/MIT-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) ViliusL +https://github.com/viliusle + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/paintplus/frontend/README.md b/paintplus/frontend/README.md new file mode 100644 index 0000000..8f0f308 --- /dev/null +++ b/paintplus/frontend/README.md @@ -0,0 +1,60 @@ +# miniPaint + +Online image editor lets you create and edit images using HTML5 technologies. No need to buy, download, install, or have outdated flash. No ads. Key features: layers, filters, open source Photoshop alternative. + +miniPaint operates directly in the browser. You can create images by pasting from the clipboard (ctrl+v) or uploading from the computer (_using menu or drag & drop_). Nothing will be sent to any server. Everything stays in your browser. + +## URL: +**https://viliusle.github.io/miniPaint/** + +## Preview: +![miniPaint](https://raw.githubusercontent.com/viliusle/miniPaint/master/images/preview.gif) +(generated using miniPaint) + +**Change log:** [/miniPaint/releases](https://github.com/viliusle/miniPaint/releases) + +## Browser Support +- Chrome +- Firefox +- Opera +- Edge +- Safari +- Yandex + +## Features + +**Files**: open images, directories, URLs, data URLs, drag and drop, save (PNG, JPG, BMP, WEBP, animated GIF, TIFF, JSON (layers data), print. + +**Edit**: undo, cut, copy, paste, selection, paste from the clipboard. + +**Image**: information, EXIF, trim, zoom, resize (Hermite resample, default resize), rotate, flip, color corrections (brightness, contrast, hue, saturation, luminance), automatic color adjustment, grid, histogram, negative. + +**Layers**: multi-layer system, differences, merging, flattening, transparency support. + +**Effects**: black and white, blur (box, gaussian, stack, zoom), bulge/pinch, denoise, desaturation, dither, dot screen, edge, emboss, enrich, gamma, grains, grayscale, heatmap, jpg compression, mosaic, oil, sepia, sharpen, solarize, tilt shift, vignette, vibrance, vintage, blueprint, night vision, pencil, also instagram filters: 1977, aden, clarendon, gingham, inkwell, lo-fi, toaster, valencia, x-pro ii. + +**Tools**: pencil, brush, magic wand, eraser, fill, color picker, letter, crop, blur, sharpener, desaturation, clone, borders, sprites, keypoints, color zoom, change color, restore transparency, content fill. + +**Help**: keyboard shortcuts, translation. + +## Embed +To embed this app on another page, use the following HTML code: + + + +## Build instructions +See [Wiki > Build instructions](https://github.com/viliusle/miniPaint/wiki/Build-instructions) + +## Wiki +See [Wiki](https://github.com/viliusle/miniPaint/wiki) + +## Contributors + + + + +## License +MIT License + +## Support +Please use the GitHub issues for support, feature requests and bug reports, or contact us by sending an email to www.viliusl@gmail.com. diff --git a/paintplus/frontend/SECURITY.md b/paintplus/frontend/SECURITY.md new file mode 100644 index 0000000..845ed38 --- /dev/null +++ b/paintplus/frontend/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| latest | :white_check_mark: | +| < latest | :x: | + +## Reporting a Vulnerability + +Please send details to www.viliusl@gmail.com diff --git a/paintplus/frontend/examples/add-edit-imgData.html b/paintplus/frontend/examples/add-edit-imgData.html new file mode 100644 index 0000000..dae792d --- /dev/null +++ b/paintplus/frontend/examples/add-edit-imgData.html @@ -0,0 +1,76 @@ + + + + + + \ No newline at end of file diff --git a/paintplus/frontend/examples/open-edit-save.html b/paintplus/frontend/examples/open-edit-save.html new file mode 100644 index 0000000..c82c6db --- /dev/null +++ b/paintplus/frontend/examples/open-edit-save.html @@ -0,0 +1,128 @@ + + + + + +
+ Click on image to edit. +

+ + + + OR + + +

+ + + + + +
+ + \ No newline at end of file diff --git a/paintplus/frontend/examples/zoom.html b/paintplus/frontend/examples/zoom.html new file mode 100644 index 0000000..a057086 --- /dev/null +++ b/paintplus/frontend/examples/zoom.html @@ -0,0 +1,71 @@ + + + + + + \ No newline at end of file diff --git a/paintplus/frontend/images/favicon.png b/paintplus/frontend/images/favicon.png new file mode 100644 index 0000000..bcd4645 Binary files /dev/null and b/paintplus/frontend/images/favicon.png differ diff --git a/paintplus/frontend/images/favicon.svg b/paintplus/frontend/images/favicon.svg new file mode 100644 index 0000000..b861938 --- /dev/null +++ b/paintplus/frontend/images/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/paintplus/frontend/images/icons/ai_edit.svg b/paintplus/frontend/images/icons/ai_edit.svg new file mode 100644 index 0000000..0d66c38 --- /dev/null +++ b/paintplus/frontend/images/icons/ai_edit.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/paintplus/frontend/images/icons/ai_inpaint.svg b/paintplus/frontend/images/icons/ai_inpaint.svg new file mode 100644 index 0000000..28f4a2e --- /dev/null +++ b/paintplus/frontend/images/icons/ai_inpaint.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/animation.svg b/paintplus/frontend/images/icons/animation.svg new file mode 100644 index 0000000..c326d23 --- /dev/null +++ b/paintplus/frontend/images/icons/animation.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/paintplus/frontend/images/icons/arrow-down.svg b/paintplus/frontend/images/icons/arrow-down.svg new file mode 100644 index 0000000..d2483ee --- /dev/null +++ b/paintplus/frontend/images/icons/arrow-down.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/paintplus/frontend/images/icons/blur.svg b/paintplus/frontend/images/icons/blur.svg new file mode 100644 index 0000000..40a6408 --- /dev/null +++ b/paintplus/frontend/images/icons/blur.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/bold.svg b/paintplus/frontend/images/icons/bold.svg new file mode 100644 index 0000000..8a31bcc --- /dev/null +++ b/paintplus/frontend/images/icons/bold.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/brush.svg b/paintplus/frontend/images/icons/brush.svg new file mode 100644 index 0000000..c4fb3ad --- /dev/null +++ b/paintplus/frontend/images/icons/brush.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/brush_select.svg b/paintplus/frontend/images/icons/brush_select.svg new file mode 100644 index 0000000..f91846d --- /dev/null +++ b/paintplus/frontend/images/icons/brush_select.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/bulge_pinch.svg b/paintplus/frontend/images/icons/bulge_pinch.svg new file mode 100644 index 0000000..0fed9ab --- /dev/null +++ b/paintplus/frontend/images/icons/bulge_pinch.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/clone.svg b/paintplus/frontend/images/icons/clone.svg new file mode 100644 index 0000000..af3ca66 --- /dev/null +++ b/paintplus/frontend/images/icons/clone.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/crop.svg b/paintplus/frontend/images/icons/crop.svg new file mode 100644 index 0000000..dd9c5f0 --- /dev/null +++ b/paintplus/frontend/images/icons/crop.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/delete.svg b/paintplus/frontend/images/icons/delete.svg new file mode 100644 index 0000000..6fc6fd9 --- /dev/null +++ b/paintplus/frontend/images/icons/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/paintplus/frontend/images/icons/desaturate.svg b/paintplus/frontend/images/icons/desaturate.svg new file mode 100644 index 0000000..5e21e11 --- /dev/null +++ b/paintplus/frontend/images/icons/desaturate.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/ellipse_select.svg b/paintplus/frontend/images/icons/ellipse_select.svg new file mode 100644 index 0000000..e910b95 --- /dev/null +++ b/paintplus/frontend/images/icons/ellipse_select.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/paintplus/frontend/images/icons/erase.svg b/paintplus/frontend/images/icons/erase.svg new file mode 100644 index 0000000..c2250bd --- /dev/null +++ b/paintplus/frontend/images/icons/erase.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/paintplus/frontend/images/icons/external.png b/paintplus/frontend/images/icons/external.png new file mode 100644 index 0000000..96de9ae Binary files /dev/null and b/paintplus/frontend/images/icons/external.png differ diff --git a/paintplus/frontend/images/icons/fill.svg b/paintplus/frontend/images/icons/fill.svg new file mode 100644 index 0000000..2e516ab --- /dev/null +++ b/paintplus/frontend/images/icons/fill.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/gradient.png b/paintplus/frontend/images/icons/gradient.png new file mode 100644 index 0000000..653f7c8 Binary files /dev/null and b/paintplus/frontend/images/icons/gradient.png differ diff --git a/paintplus/frontend/images/icons/grid.png b/paintplus/frontend/images/icons/grid.png new file mode 100644 index 0000000..8c7ede9 Binary files /dev/null and b/paintplus/frontend/images/icons/grid.png differ diff --git a/paintplus/frontend/images/icons/italic.svg b/paintplus/frontend/images/icons/italic.svg new file mode 100644 index 0000000..3bcedce --- /dev/null +++ b/paintplus/frontend/images/icons/italic.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/lasso.svg b/paintplus/frontend/images/icons/lasso.svg new file mode 100644 index 0000000..04e99b0 --- /dev/null +++ b/paintplus/frontend/images/icons/lasso.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/paintplus/frontend/images/icons/magic_erase.svg b/paintplus/frontend/images/icons/magic_erase.svg new file mode 100644 index 0000000..1d4f16b --- /dev/null +++ b/paintplus/frontend/images/icons/magic_erase.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/paintplus/frontend/images/icons/magic_wand.svg b/paintplus/frontend/images/icons/magic_wand.svg new file mode 100644 index 0000000..a44d444 --- /dev/null +++ b/paintplus/frontend/images/icons/magic_wand.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/media.svg b/paintplus/frontend/images/icons/media.svg new file mode 100644 index 0000000..a9cd09a --- /dev/null +++ b/paintplus/frontend/images/icons/media.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/menu.svg b/paintplus/frontend/images/icons/menu.svg new file mode 100644 index 0000000..e06501c --- /dev/null +++ b/paintplus/frontend/images/icons/menu.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/pencil.svg b/paintplus/frontend/images/icons/pencil.svg new file mode 100644 index 0000000..fb965a9 --- /dev/null +++ b/paintplus/frontend/images/icons/pencil.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/pick_color.svg b/paintplus/frontend/images/icons/pick_color.svg new file mode 100644 index 0000000..9c68ef3 --- /dev/null +++ b/paintplus/frontend/images/icons/pick_color.svg @@ -0,0 +1,19 @@ + + + + + + diff --git a/paintplus/frontend/images/icons/refresh.svg b/paintplus/frontend/images/icons/refresh.svg new file mode 100644 index 0000000..205fb29 --- /dev/null +++ b/paintplus/frontend/images/icons/refresh.svg @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/paintplus/frontend/images/icons/select.svg b/paintplus/frontend/images/icons/select.svg new file mode 100644 index 0000000..0fb00e1 --- /dev/null +++ b/paintplus/frontend/images/icons/select.svg @@ -0,0 +1,14 @@ + + + + + + + diff --git a/paintplus/frontend/images/icons/selection.svg b/paintplus/frontend/images/icons/selection.svg new file mode 100644 index 0000000..83f92fc --- /dev/null +++ b/paintplus/frontend/images/icons/selection.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/shape.svg b/paintplus/frontend/images/icons/shape.svg new file mode 100644 index 0000000..a8d06d4 --- /dev/null +++ b/paintplus/frontend/images/icons/shape.svg @@ -0,0 +1,6 @@ + + shapes + + + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/sharpen.svg b/paintplus/frontend/images/icons/sharpen.svg new file mode 100644 index 0000000..1945d9f --- /dev/null +++ b/paintplus/frontend/images/icons/sharpen.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/paintplus/frontend/images/icons/smart_select.svg b/paintplus/frontend/images/icons/smart_select.svg new file mode 100644 index 0000000..7dba581 --- /dev/null +++ b/paintplus/frontend/images/icons/smart_select.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/icons/strikethrough.svg b/paintplus/frontend/images/icons/strikethrough.svg new file mode 100644 index 0000000..70d5806 --- /dev/null +++ b/paintplus/frontend/images/icons/strikethrough.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/text.svg b/paintplus/frontend/images/icons/text.svg new file mode 100644 index 0000000..ca2d7bf --- /dev/null +++ b/paintplus/frontend/images/icons/text.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/paintplus/frontend/images/icons/underline.svg b/paintplus/frontend/images/icons/underline.svg new file mode 100644 index 0000000..9a2f8fe --- /dev/null +++ b/paintplus/frontend/images/icons/underline.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/undo.svg b/paintplus/frontend/images/icons/undo.svg new file mode 100644 index 0000000..97eda4d --- /dev/null +++ b/paintplus/frontend/images/icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/paintplus/frontend/images/icons/view.svg b/paintplus/frontend/images/icons/view.svg new file mode 100644 index 0000000..4fff164 --- /dev/null +++ b/paintplus/frontend/images/icons/view.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + diff --git a/paintplus/frontend/images/logo-colors.png b/paintplus/frontend/images/logo-colors.png new file mode 100644 index 0000000..8970d09 Binary files /dev/null and b/paintplus/frontend/images/logo-colors.png differ diff --git a/paintplus/frontend/images/logo.svg b/paintplus/frontend/images/logo.svg new file mode 100644 index 0000000..f7f6c8c --- /dev/null +++ b/paintplus/frontend/images/logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/paintplus/frontend/images/manifest/144x144.png b/paintplus/frontend/images/manifest/144x144.png new file mode 100644 index 0000000..7c1fd4b Binary files /dev/null and b/paintplus/frontend/images/manifest/144x144.png differ diff --git a/paintplus/frontend/images/manifest/168x168.png b/paintplus/frontend/images/manifest/168x168.png new file mode 100644 index 0000000..4c21ca1 Binary files /dev/null and b/paintplus/frontend/images/manifest/168x168.png differ diff --git a/paintplus/frontend/images/manifest/192x192.png b/paintplus/frontend/images/manifest/192x192.png new file mode 100644 index 0000000..6ded28d Binary files /dev/null and b/paintplus/frontend/images/manifest/192x192.png differ diff --git a/paintplus/frontend/images/manifest/48x48.png b/paintplus/frontend/images/manifest/48x48.png new file mode 100644 index 0000000..2cb8b5e Binary files /dev/null and b/paintplus/frontend/images/manifest/48x48.png differ diff --git a/paintplus/frontend/images/manifest/72x72.png b/paintplus/frontend/images/manifest/72x72.png new file mode 100644 index 0000000..6ec2431 Binary files /dev/null and b/paintplus/frontend/images/manifest/72x72.png differ diff --git a/paintplus/frontend/images/manifest/96x96.png b/paintplus/frontend/images/manifest/96x96.png new file mode 100644 index 0000000..7a934d3 Binary files /dev/null and b/paintplus/frontend/images/manifest/96x96.png differ diff --git a/paintplus/frontend/images/preview.gif b/paintplus/frontend/images/preview.gif new file mode 100644 index 0000000..eb6ea20 Binary files /dev/null and b/paintplus/frontend/images/preview.gif differ diff --git a/paintplus/frontend/images/preview.jpg b/paintplus/frontend/images/preview.jpg new file mode 100644 index 0000000..31dc5ee Binary files /dev/null and b/paintplus/frontend/images/preview.jpg differ diff --git a/paintplus/frontend/images/test-collection.json b/paintplus/frontend/images/test-collection.json new file mode 100644 index 0000000..d0ac2b7 --- /dev/null +++ b/paintplus/frontend/images/test-collection.json @@ -0,0 +1,1809 @@ +{ + "info": { + "width": 1000, + "height": 750, + "about": "Image data with multi-layers. Can be opened using miniPaint - https://github.com/viliusle/miniPaint", + "date": "2021-06-16", + "version": "4.7.1", + "layer_active": 1, + "guides": [ + { "x": null, "y": 400}, + { "x": null, "y": 600}, + { "x": null, "y": 650} + ] + }, + "layers": [ + { + "id": 1, + "parent_id": 0, + "name": "Image", + "type": "image", + "link": {}, + "x": 50, + "y": 100, + "width": 300, + "width_original": 400, + "height": 239, + "height_original": 380, + "visible": true, + "is_vector": false, + "hide_selection_if_active": false, + "opacity": 100, + "order": 1, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": {}, + "status": null, + "color": "#008000", + "filters": [ + { + "id": 592629679, + "name": "shadow", + "params": { + "x": 10, + "y": 10, + "value": "5", + "color": "#000000" + } + } + ], + "render_function": null + }, + { + "id": 2, + "parent_id": 0, + "name": "Text", + "type": "text", + "link": null, + "x": 49, + "y": 25, + "width": 522, + "width_original": null, + "height": 62, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 2, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "size": 45, + "bold": true, + "italic": false, + "stroke": false, + "align": { + "value": "Left", + "values": [ + "Left", + "Center", + "Right" + ] + }, + "family": { + "value": "Arial", + "values": [ + "Arial", + "Courier", + "Impact", + "Helvetica", + "monospace", + "Times New Roman", + "Verdana" + ] + }, + "stroke_size": 1, + "text": "MiniPaint test collection" + }, + "status": null, + "color": "#323e6c", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 3, + "parent_id": 0, + "name": "Text (hidden)", + "type": "text", + "link": null, + "x": 580, + "y": 34, + "width": 326, + "width_original": null, + "height": 34, + "height_original": null, + "visible": false, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 3, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "size": 30, + "bold": true, + "italic": false, + "stroke": false, + "align": { + "value": "Left", + "values": [ + "Left", + "Center", + "Right" + ] + }, + "family": { + "value": "Arial", + "values": [ + "Arial", + "Courier", + "Impact", + "Helvetica", + "monospace", + "Times New Roman", + "Verdana" + ] + }, + "stroke_size": 1, + "text": "INVISIBLE TEXT" + }, + "status": null, + "color": "#bb0004", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 4, + "parent_id": 0, + "name": "Text types", + "type": "text", + "link": null, + "x": 650, + "y": 100, + "width": 300, + "width_original": null, + "height": 213, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 4, + "composition": "source-over", + "rotate": 0, + "data": [ + [ + { + "meta": { + "size": 30, + "bold": true, + "fill_color": "#646464" + }, + "text": "Bold " + }, + { + "meta": { + "size": 30, + "italic": true, + "fill_color": "#646464" + }, + "text": "italic " + }, + { + "meta": { + "size": 30, + "underline": true, + "fill_color": "#646464" + }, + "text": "underline" + } + ], + [ + { + "meta": { + "size": 30, + "strikethrough": true, + "fill_color": "#646464" + }, + "text": "strikethrough" + }, + { + "text": " ", + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464" + } + }, + { + "meta": { + "size": 20, + "strikethrough": false, + "fill_color": "#646464" + }, + "text": "small" + }, + { + "text": " ", + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464" + } + }, + { + "text": "big", + "meta": { + "size": 60, + "strikethrough": false, + "fill_color": "#646464" + } + }, + { + "text": " red ", + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#ff0000" + } + }, + { + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#c8c8c8", + "stroke_color": "#0000ff", + "stroke_size": 1 + }, + "text": "stroke" + } + ], + [ + { + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464", + "stroke_color": "#008800", + "stroke_size": 0 + }, + "text": "Arial " + }, + { + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464", + "stroke_color": "#008800", + "stroke_size": 0, + "family": "Courier" + }, + "text": "Courier " + }, + { + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464", + "stroke_color": "#008800", + "stroke_size": 0, + "family": "Special Elite" + }, + "text": "Elite" + } + ], + [ + { + "meta": { + "size": 30, + "strikethrough": false, + "fill_color": "#646464", + "stroke_color": "#008800", + "stroke_size": 0, + "family": "Tahoma", + "kerning": 15 + }, + "text": "Kerning" + } + ] + ], + "params": { + "boundary": "box", + "text_direction": "ltr", + "wrap_direction": "ttb", + "halign": "left", + "valign": "top", + "wrap": "letter" + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 5, + "parent_id": 0, + "name": "Text - center", + "type": "text", + "link": null, + "x": 650, + "y": 309.5, + "width": 300, + "width_original": null, + "height": 41, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 5, + "composition": "source-over", + "rotate": 0, + "data": [ + [ + { + "text": "Center", + "meta": { + "family": "Tahoma", + "size": 25, + "fill_color": "#646464", + "stroke_color": "#008800" + } + } + ] + ], + "params": { + "boundary": "box", + "text_direction": "ltr", + "wrap_direction": "ttb", + "halign": "center", + "valign": "top", + "wrap": "letter" + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 6, + "parent_id": 0, + "name": "Text - right", + "type": "text", + "link": null, + "x": 650, + "y": 350, + "width": 300, + "width_original": null, + "height": 43, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 6, + "composition": "source-over", + "rotate": 0, + "data": [ + [ + { + "text": "Right side", + "meta": { + "family": "Tahoma", + "size": 25, + "fill_color": "#646464", + "stroke_color": "#008800" + } + } + ] + ], + "params": { + "boundary": "box", + "text_direction": "ltr", + "wrap_direction": "ttb", + "halign": "right", + "valign": "top", + "wrap": "letter" + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 7, + "parent_id": 0, + "name": "Text - wrap", + "type": "text", + "link": null, + "x": 400, + "y": 270, + "width": 230, + "width_original": null, + "height": 116, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 7, + "composition": "source-over", + "rotate": 0, + "data": [ + [ + { + "meta": { + "size": 16, + "fill_color": "#323232", + "stroke_color": "#0000ff", + "family": "Verdana", + "leading": 4 + }, + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque dictum, ipsum sed bibendum posuere, eros erat blandit justo." + } + ] + ], + "params": { + "boundary": "box", + "text_direction": "ltr", + "wrap_direction": "ttb", + "halign": "left", + "valign": "top", + "wrap": "letter" + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "text", + "render" + ] + }, + { + "id": 8, + "parent_id": 0, + "name": "Line", + "type": "line", + "link": null, + "x": 50, + "y": 600, + "width": 30, + "width_original": null, + "height": -200, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 8, + "composition": "source-over", + "rotate": null, + "data": null, + "params": { + "size": 5 + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "line", + "render" + ] + }, + { + "id": 9, + "parent_id": 0, + "name": "Arrow", + "type": "arrow", + "link": null, + "x": 130, + "y": 400, + "width": -30, + "width_original": null, + "height": 200, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 9, + "composition": "source-over", + "rotate": null, + "data": null, + "params": { + "size": 5 + }, + "status": null, + "color": "#606060", + "filters": [], + "render_function": [ + "arrow", + "render" + ] + }, + { + "id": 10, + "parent_id": 0, + "name": "Rectangle", + "type": "rectangle", + "link": null, + "x": 260, + "y": 500, + "width": 50, + "width_original": null, + "height": 100, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 10, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "fill": false, + "square": false, + "border_size": 4, + "border": true, + "border_color": "#1b1bd8", + "fill_color": "#1b1bd8" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "rectangle", + "render" + ] + }, + { + "id": 11, + "parent_id": 0, + "name": "Ellipse", + "type": "ellipse", + "link": null, + "x": 330, + "y": 400, + "width": 50, + "width_original": null, + "height": 90, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 11, + "composition": "source-over", + "rotate": 0, + "data": { + "center_x": 927, + "center_y": 28 + }, + "params": { + "fill": true, + "circle": false, + "border_size": 1, + "border": false, + "border_color": "#c80000", + "fill_color": "#c80000" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "ellipse", + "render" + ] + }, + { + "id": 12, + "parent_id": 0, + "name": "Gradient (radial)", + "type": "gradient", + "link": null, + "x": 915, + "y": 40, + "width": 20, + "width_original": null, + "height": 20, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 12, + "composition": "source-over", + "rotate": null, + "data": { + "center_x": 486, + "center_y": 504 + }, + "params": { + "color_1": "#008000", + "color_2": "#ffffff", + "alpha": 0, + "radial": true, + "radial_power": 50 + }, + "status": null, + "color": "#40ff40", + "filters": [], + "render_function": [ + "gradient", + "render" + ] + }, + { + "id": 13, + "parent_id": 0, + "name": "Gradient (linear)", + "type": "gradient", + "link": null, + "x": 985, + "y": 56, + "width": -100, + "width_original": null, + "height": 50, + "height_original": null, + "visible": true, + "is_vector": false, + "hide_selection_if_active": false, + "opacity": 100, + "order": 13, + "composition": "source-over", + "rotate": null, + "data": { + "center_x": 1185, + "center_y": 540 + }, + "params": { + "color_1": "#008000", + "color_2": "#ffffff", + "alpha": 0, + "radial": false, + "radial_power": 50 + }, + "status": null, + "color": "#c4f5b2", + "filters": [], + "render_function": [ + "gradient", + "render" + ] + }, + { + "id": 14, + "parent_id": 0, + "name": "Borders", + "type": "borders", + "link": null, + "x": 0, + "y": 0, + "width": 1000, + "width_original": null, + "height": 750, + "height_original": null, + "visible": true, + "is_vector": false, + "hide_selection_if_active": false, + "opacity": 100, + "order": 14, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "size": 15, + "shadow": false + }, + "status": null, + "color": "#02740f", + "filters": [], + "render_function": [ + "borders", + "render" + ] + }, + { + "id": 15, + "parent_id": 0, + "name": "Rectangle (radius)", + "type": "rectangle", + "link": null, + "x": 260, + "y": 400, + "width": 50, + "width_original": null, + "height": 90, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 15, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "radius": 999, + "fill": true, + "square": false, + "border_size": 1, + "border": false, + "border_color": "#6b6b6b", + "fill_color": "#6b6b6b" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "rectangle", + "render" + ] + }, + { + "id": 16, + "parent_id": 0, + "name": "Rectangle", + "type": "rectangle", + "link": null, + "x": 177, + "y": 400, + "width": 30, + "width_original": null, + "height": 150, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 16, + "composition": "source-over", + "rotate": 10, + "data": null, + "params": { + "fill": true, + "square": false, + "border_size": 1, + "border": false, + "border_color": "#55b955", + "fill_color": "#55b955" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "rectangle", + "render" + ] + }, + { + "id": 17, + "parent_id": 0, + "name": "Rectangle (composition)", + "type": "rectangle", + "link": null, + "x": 147, + "y": 424, + "width": 100, + "width_original": null, + "height": 10, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 17, + "composition": "xor", + "rotate": 0, + "data": null, + "params": { + "radius": 0, + "fill": true, + "square": false, + "border_size": 1, + "border": false, + "border_color": "#6b6b6b", + "fill_color": "#6b6b6b" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "rectangle", + "render" + ] + }, + { + "id": 18, + "parent_id": 0, + "name": "Rectangle (opacity)", + "type": "rectangle", + "link": null, + "x": 177, + "y": 445, + "width": 30, + "width_original": null, + "height": 150, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 50, + "order": 18, + "composition": "source-over", + "rotate": -15, + "data": null, + "params": { + "fill": true, + "square": false, + "border_size": 1, + "border": false, + "border_color": "#e01a1a", + "fill_color": "#e01a1a" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "rectangle", + "render" + ] + }, + { + "id": 19, + "parent_id": 0, + "name": "Brush", + "type": "brush", + "link": null, + "x": 410, + "y": 120, + "width": 82, + "width_original": null, + "height": 49, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 19, + "composition": "source-over", + "rotate": null, + "data": [ + [ + [0,48,4],[0,38,4],[1,28,4],[4,18,4],[7,10,4],[8,6,4],[8,0,4],[10,2,4],[14,10,4],[16,14,4], + [19,19,4],[21,20,4],[24,22,4],[25,22,4],[27,22,4],[31,22,4],[34,20,4],[41,13,4],[45,8,4], + [49,5,4],[52,3,4],[53,2,4],[54,2,4],[55,2,4],[59,5,4],[61,15,4],[62,21,4],[62,25,4],[62,26,4], + [62,26,4] + ], + [ + [8,52,4],[23,49,4],[38,46,4],[53,43,4],[63,39,4],[70,37,4],[74,36,4],[76,36,4] + ] + ], + "params": { + "size": 4, + "pressure": false + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "brush", + "render" + ] + }, + { + "id": 20, + "parent_id": 0, + "name": "Brush (pressure)", + "type": "brush", + "link": null, + "x": 530, + "y": 120, + "width": 82, + "width_original": null, + "height": 49, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 20, + "composition": "source-over", + "rotate": null, + "data": [ + [ + [0,48,4],[0,38,4],[1,28,4],[4,18,4],[7,10,4],[8,6,4],[8,0,4],[10,2,4],[14,10,4],[16,14,4], + [19,19,4],[21,20,4],[24,22,4],[25,22,4],[27,22,4],[31,22,4],[34,20,4],[41,13,4],[45,8,3], + [49,5,3],[52,3,3],[53,2,3],[54,2,2],[55,2,2],[59,5,2],[61,15,2],[62,21,1],[62,25,1],[62,26,1], + [62,26,1] + ], + [ + [8,52,1],[23,49,2],[38,46,3],[53,43,4],[63,39,5],[70,37,4],[74,36,3],[76,36,2] + ] + ], + "params": { + "size": 4, + "pressure": true + }, + "status": null, + "color": "#008000", + "filters": [], + "render_function": [ + "brush", + "render" + ] + }, + { + "id": 21, + "parent_id": 0, + "name": "Pencil (antialiasing)", + "type": "pencil", + "link": null, + "x": 410, + "y": 200, + "width": 53, + "width_original": null, + "height": 43, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 21, + "composition": "source-over", + "rotate": null, + "data": [ + [0,39],[4,31],[8,19],[11,15],[13,13],[14,16],[16,21],[18,26],[19,28],[21,28],[26,21],[37,7],[42,1], + [43,0],[43,5],[43,9],[44,13],[45,16],[46,19],[46,21],[46,21],null,[12,43],[20,41],[28,39],[37,35], + [42,33],[46,31],[47,31],[49,30],[51,29],[53,28],[53,28] + ], + "params": { + "antialiasing": true, + "size": 2 + }, + "status": null, + "color": "#ff0000", + "filters": [], + "render_function": [ + "pencil", + "render" + ] + }, + { + "id": 22, + "parent_id": 0, + "name": "Pencil", + "type": "pencil", + "link": null, + "x": 530, + "y": 200, + "width": 40, + "width_original": null, + "height": 35, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 22, + "composition": "source-over", + "rotate": null, + "data": [ + [0,28],[1,21],[5,13],[7,11],[7,10],[9,14],[11,20],[12,23],[12,24],[13,24],[17,22],[25,13],[33,3],[35,0], + [35,4],[35,9],[34,15],[34,17],[34,18],[34,18],null,[5,35],[17,32],[27,29],[31,28],[36,27],[38,26], + [39,26],[40,26],[40,26] + ], + "params": { + "antialiasing": false, + "size": 2 + }, + "status": null, + "color": "#ff0000", + "filters": [], + "render_function": [ + "pencil", + "render" + ] + }, + { + "id": 23, + "parent_id": 0, + "name": "Cylinder", + "type": "cylinder", + "link": null, + "x": 411, + "y": 400, + "width": 48, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 23, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "cylinder", + "render" + ] + }, + { + "id": 24, + "parent_id": 0, + "name": "Heart", + "type": "heart", + "link": null, + "x": 830, + "y": 541, + "width": 70, + "width_original": null, + "height": 58, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 24, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "heart", + "render" + ] + }, + { + "id": 25, + "parent_id": 0, + "name": "Hexagon", + "type": "hexagon", + "link": null, + "x": 480, + "y": 400, + "width": 70, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 25, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "hexagon", + "render" + ] + }, + { + "id": 26, + "parent_id": 0, + "name": "Pentagon", + "type": "pentagon", + "link": null, + "x": 480, + "y": 530, + "width": 70, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 26, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "pentagon", + "render" + ] + }, + { + "id": 27, + "parent_id": 0, + "name": "Parallelogram", + "type": "parallelogram", + "link": null, + "x": 570, + "y": 400, + "width": 70, + "width_original": null, + "height": 50, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 27, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "parallelogram", + "render" + ] + }, + { + "id": 28, + "parent_id": 0, + "name": "Trapezoid", + "type": "trapezoid", + "link": null, + "x": 570, + "y": 558, + "width": 70, + "width_original": null, + "height": 40, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 28, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "trapezoid", + "render" + ] + }, + { + "id": 29, + "parent_id": 0, + "name": "Plus", + "type": "plus", + "link": null, + "x": 650, + "y": 400, + "width": 60, + "width_original": null, + "height": 60, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 29, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "plus", + "render" + ] + }, + { + "id": 30, + "parent_id": 0, + "name": "Right_triangle", + "type": "right_triangle", + "link": null, + "x": 740, + "y": 400, + "width": 70, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 30, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "right_triangle", + "render" + ] + }, + { + "id": 31, + "parent_id": 0, + "name": "Triangle", + "type": "triangle", + "link": null, + "x": 740, + "y": 528, + "width": 81, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 31, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "triangle", + "render" + ] + }, + { + "id": 32, + "parent_id": 0, + "name": "Romb", + "type": "romb", + "link": null, + "x": 410, + "y": 536, + "width": 50, + "width_original": null, + "height": 63, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 32, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "romb", + "render" + ] + }, + { + "id": 34, + "parent_id": 0, + "name": "Star", + "type": "star", + "link": null, + "x": 320, + "y": 529, + "width": 70, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 34, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "star", + "render" + ] + }, + { + "id": 35, + "parent_id": 0, + "name": "Star-24", + "type": "star24", + "link": null, + "x": 830, + "y": 400, + "width": 70, + "width_original": null, + "height": 70, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 35, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "star24", + "render" + ] + }, + { + "id": 39, + "parent_id": 0, + "name": "Plus (borders)", + "type": "plus", + "link": null, + "x": 620, + "y": 466, + "width": 50, + "width_original": null, + "height": 50, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 39, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": false, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "plus", + "render" + ] + }, + { + "id": 40, + "parent_id": 0, + "name": "Plus (filled)", + "type": "plus", + "link": null, + "x": 680, + "y": 466, + "width": 50, + "width_original": null, + "height": 50, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 40, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": false, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "plus", + "render" + ] + }, + { + "id": 41, + "parent_id": 0, + "name": "Plus (rotated)", + "type": "plus", + "link": null, + "x": 660, + "y": 551, + "width": 50, + "width_original": null, + "height": 50, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 41, + "composition": "source-over", + "rotate": 47, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "plus", + "render" + ] + }, + { + "id": 42, + "parent_id": 0, + "name": "Human", + "type": "human", + "link": null, + "x": 925, + "y": 400, + "width": 35, + "width_original": null, + "height": 100, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 42, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "human", + "render" + ] + }, + { + "id": 43, + "parent_id": 0, + "name": "Cog", + "type": "cog", + "link": null, + "x": 50, + "y": 650, + "width": 77, + "width_original": null, + "height": 77, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 43, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "fill_color": "#555555" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "cog", + "render" + ] + }, + { + "id": 44, + "parent_id": 0, + "name": "Tear", + "type": "tear", + "link": null, + "x": 140, + "y": 650, + "width": 61, + "width_original": null, + "height": 76.25, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 44, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "tear", + "render" + ] + }, + { + "id": 45, + "parent_id": 0, + "name": "Moon", + "type": "moon", + "link": null, + "x": 220, + "y": 650, + "width": 58, + "width_original": null, + "height": 72.5, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 45, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "moon", + "render" + ] + }, + { + "id": 46, + "parent_id": 0, + "name": "Callout", + "type": "callout", + "link": null, + "x": 300, + "y": 650, + "width": 81.60000000000001, + "width_original": null, + "height": 69, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": false, + "opacity": 100, + "order": 46, + "composition": "source-over", + "rotate": 0, + "data": null, + "params": { + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": null, + "color": null, + "filters": [], + "render_function": [ + "callout", + "render" + ] + }, + { + "id": 48, + "parent_id": 0, + "name": "Bezier_curve #48", + "type": "bezier_curve", + "link": null, + "x": 0, + "y": 0, + "width": null, + "width_original": null, + "height": null, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 48, + "composition": "source-over", + "rotate": null, + "data": { + "start": {"x": 400, "y": 660.5}, + "cp1": {"x": 477.5, "y": 661.5}, + "cp2": {"x": 400, "y": 708}, + "end": {"x": 480, "y": 707.5} + }, + "params": { + "size": 4 + }, + "status": null, + "color": "#ff0000", + "filters": [], + "render_function": [ + "bezier_curve", + "render" + ] + }, + { + "id": 49, + "parent_id": 0, + "name": "Polygon #49", + "type": "polygon", + "link": null, + "x": 0, + "y": 0, + "width": null, + "width_original": null, + "height": null, + "height_original": null, + "visible": true, + "is_vector": true, + "hide_selection_if_active": true, + "opacity": 100, + "order": 49, + "composition": "source-over", + "rotate": null, + "data": [ + {"x": 950, "y": 551}, + {"x": 950, "y": 599}, + {"x": 900, "y": 599}, + {"x": 935, "y": 586}, + {"x": 915, "y": 533.2} + ], + "params": { + "size": 4, + "border_size": 4, + "border": true, + "fill": true, + "border_color": "#555555", + "fill_color": "#aaaaaa" + }, + "status": "draft", + "color": "#ff0000", + "filters": [], + "render_function": [ + "polygon", + "render" + ] + } + ], + "data": [ + { + "id": 1, + "data": "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8' standalone='no'%3F%3E%3C!-- Created with Inkscape (http://www.inkscape.org/) --%3E%3Csvg xmlns:svg='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg' version='1.0' width='400' height='380' id='svg2'%3E%3Cdefs id='defs5' /%3E%3Cpath d='M 151.34904,307.20455 L 264.34904,307.20455 C 264.34904,291.14096 263.2021,287.95455 236.59904,287.95455 C 240.84904,275.20455 258.12424,244.35808 267.72404,244.35808 C 276.21707,244.35808 286.34904,244.82592 286.34904,264.20455 C 286.34904,286.20455 323.37171,321.67547 332.34904,307.20455 C 345.72769,285.63897 309.34904,292.21514 309.34904,240.20455 C 309.34904,169.05135 350.87417,179.18071 350.87417,139.20455 C 350.87417,119.20455 345.34904,116.50374 345.34904,102.20455 C 345.34904,83.30695 361.99717,84.403577 358.75805,68.734879 C 356.52061,57.911656 354.76962,49.23199 353.46516,36.143889 C 352.53959,26.857305 352.24452,16.959398 342.59855,17.357382 C 331.26505,17.824992 326.96549,37.77419 309.34904,39.204549 C 291.76851,40.631991 276.77834,24.238028 269.97404,26.579549 C 263.22709,28.901334 265.34904,47.204549 269.34904,60.204549 C 275.63588,80.636771 289.34904,107.20455 264.34904,111.20455 C 239.34904,115.20455 196.34904,119.20455 165.34904,160.20455 C 134.34904,201.20455 135.49342,249.3212 123.34904,264.20455 C 82.590696,314.15529 40.823919,293.64625 40.823919,335.20455 C 40.823919,353.81019 72.349045,367.20455 77.349045,361.20455 C 82.349045,355.20455 34.863764,337.32587 87.995492,316.20455 C 133.38711,298.16014 137.43914,294.47663 151.34904,307.20455 z ' style='fill:%23a70d0d;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1' id='path1891' /%3E%3C/svg%3E%0A" + } + ] +} diff --git a/paintplus/frontend/index.html b/paintplus/frontend/index.html new file mode 100644 index 0000000..643e5c0 --- /dev/null +++ b/paintplus/frontend/index.html @@ -0,0 +1,104 @@ + + + + + + PaintPlus - AI Image Editor + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + +
+
+
+
+ +
+ Your browser does not support canvas or JavaScript is not enabled. +
+
+
+
+
+ + +
+
+ + +
+ +
+ + diff --git a/paintplus/frontend/manifest-disabled.json b/paintplus/frontend/manifest-disabled.json new file mode 100644 index 0000000..51f4934 --- /dev/null +++ b/paintplus/frontend/manifest-disabled.json @@ -0,0 +1,41 @@ +{ + "name": "miniPaint", + "short_name": "miniPaint", + "start_url": "/", + "display": "standalone", + "orientation": "landscape", + "background_color": "#666d6f", + "description": "miniPaint is free online image editor using HTML5.", + "icons": [ + { + "src": "images/manifest/48x48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "images/manifest/72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "images/manifest/96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "images/manifest/144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "images/manifest/168x168.png", + "sizes": "168x168", + "type": "image/png" + }, + { + "src": "images/manifest/192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/paintplus/frontend/nginx.conf b/paintplus/frontend/nginx.conf new file mode 100644 index 0000000..6fd904d --- /dev/null +++ b/paintplus/frontend/nginx.conf @@ -0,0 +1,98 @@ +server { + listen 80; + server_name localhost; + + # Allow large file uploads (50MB) + client_max_body_size 50M; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json; + + # React Router + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy - proxy all API routes to backend + location /projects { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /edits { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /patches { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /tools { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + } + + location /generate { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /health { + proxy_pass http://backend:8000; + } + + # API prefix for frontend (maps /api/tools to /tools, etc.) + location /api/ { + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + } + + location /docs { + proxy_pass http://backend:8000; + } + + location /openapi.json { + proxy_pass http://backend:8000; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/paintplus/frontend/package-lock.json b/paintplus/frontend/package-lock.json new file mode 100644 index 0000000..00a545d --- /dev/null +++ b/paintplus/frontend/package-lock.json @@ -0,0 +1,10750 @@ +{ + "name": "miniPaint", + "version": "4.14.2", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "miniPaint", + "version": "4.14.2", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.14.5", + "alertifyjs": "^1.13.1", + "blueimp-canvas-to-blob": "^3.28.0", + "exif-js": "^2.3.0", + "file-saver": "^2.0.5", + "fuzzysort": "^1.1.4", + "gif.js.optimized": "^1.0.1", + "hermite-resize": "git+https://github.com/viliusle/Hermite-resize.git", + "jquery": "^3.5.1", + "pica": "^7.0.0", + "semver-compare": "^1.0.0", + "uuid": "^8.3.2", + "webfontloader": "^1.6.28" + }, + "devDependencies": { + "@babel/core": "^7.14.5", + "@babel/plugin-transform-runtime": "^7.14.5", + "@babel/preset-env": "^7.14.5", + "babel-loader": "^8.2.2", + "css-loader": "^5.2.6", + "source-map-loader": "^3.0.0", + "style-loader": "^2.0.0", + "webpack": "^5.76.0", + "webpack-cli": "^4.7.2", + "webpack-dev-server": "^4.3.1" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", + "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.5", + "@babel/parser": "^7.23.5", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.5.tgz", + "integrity": "sha512-BPssCHrBD+0YrxviOa3QzpqwhNIXKEtOa2jQrm4FlmkC2apYgRnQcmPWiGZDlGxiNtltnUFolMe8497Esry+jA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.5.tgz", + "integrity": "sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", + "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", + "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-optimise-call-expression": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "dev": true, + "dependencies": { + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.5.tgz", + "integrity": "sha512-oO7us8FzTEsG3U6ag9MfdF1iA/7Z6dz+MtFhifZk8C8o453rGJFFWUP1t+ULM9TUIAzC9uxXEiXjOiVMyd7QPg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.5.tgz", + "integrity": "sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", + "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", + "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.23.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.3.tgz", + "integrity": "sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", + "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", + "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", + "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.4.tgz", + "integrity": "sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", + "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", + "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", + "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", + "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", + "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz", + "integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-split-export-declaration": "^7.22.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", + "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/template": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", + "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", + "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", + "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", + "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", + "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", + "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.3.tgz", + "integrity": "sha512-X8jSm8X1CMwxmK878qsUGJRmbysKNbdpTv/O1/v0LuY/ZkZrng5WYiekYSdg9m09OTmDDUWeEDsTE+17WYbAZw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", + "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", + "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", + "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", + "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", + "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", + "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", + "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", + "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", + "dev": true, + "dependencies": { + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", + "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", + "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", + "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", + "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", + "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.23.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", + "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", + "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", + "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", + "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", + "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", + "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", + "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", + "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", + "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.4.tgz", + "integrity": "sha512-ITwqpb6V4btwUG0YJR82o2QvmWrLgDnx/p2A3CTPYGaRgULkDiC0DRA2C4jlRB9uXGUEfaSS/IGHfVW+ohzYDw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", + "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", + "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", + "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", + "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", + "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", + "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", + "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", + "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", + "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.5.tgz", + "integrity": "sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.4", + "@babel/plugin-transform-async-to-generator": "^7.23.3", + "@babel/plugin-transform-block-scoped-functions": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.4", + "@babel/plugin-transform-class-properties": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.4", + "@babel/plugin-transform-classes": "^7.23.5", + "@babel/plugin-transform-computed-properties": "^7.23.3", + "@babel/plugin-transform-destructuring": "^7.23.3", + "@babel/plugin-transform-dotall-regex": "^7.23.3", + "@babel/plugin-transform-duplicate-keys": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.4", + "@babel/plugin-transform-exponentiation-operator": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", + "@babel/plugin-transform-for-of": "^7.23.3", + "@babel/plugin-transform-function-name": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.4", + "@babel/plugin-transform-literals": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", + "@babel/plugin-transform-member-expression-literals": "^7.23.3", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-umd": "^7.23.3", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", + "@babel/plugin-transform-numeric-separator": "^7.23.4", + "@babel/plugin-transform-object-rest-spread": "^7.23.4", + "@babel/plugin-transform-object-super": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.4", + "@babel/plugin-transform-optional-chaining": "^7.23.4", + "@babel/plugin-transform-parameters": "^7.23.3", + "@babel/plugin-transform-private-methods": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.4", + "@babel/plugin-transform-property-literals": "^7.23.3", + "@babel/plugin-transform-regenerator": "^7.23.3", + "@babel/plugin-transform-reserved-words": "^7.23.3", + "@babel/plugin-transform-shorthand-properties": "^7.23.3", + "@babel/plugin-transform-spread": "^7.23.3", + "@babel/plugin-transform-sticky-regex": "^7.23.3", + "@babel/plugin-transform-template-literals": "^7.23.3", + "@babel/plugin-transform-typeof-symbol": "^7.23.3", + "@babel/plugin-transform-unicode-escapes": "^7.23.3", + "@babel/plugin-transform-unicode-property-regex": "^7.23.3", + "@babel/plugin-transform-unicode-regex": "^7.23.3", + "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.5.tgz", + "integrity": "sha512-czx7Xy5a6sapWWRx61m1Ke1Ra4vczu1mCTtJam5zRTBOonfdJ+S/B6HYmGYu3fJtr8GGET3si6IhgWVBhJ/m8w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.5", + "@babel/types": "^7.23.5", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.5.tgz", + "integrity": "sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.44.8", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.8.tgz", + "integrity": "sha512-4K8GavROwhrYl2QXDXm0Rv9epkA8GBFu0EI+XrrnnuCl7u8CWBRusX7fXJfanhZTDWSAL24gDI/UqXyUM0Injw==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.41", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", + "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz", + "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.10.tgz", + "integrity": "sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.9.10", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", + "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/alertifyjs": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/alertifyjs/-/alertifyjs-1.13.1.tgz", + "integrity": "sha512-CckZE2dZDsEEXglOXKxT00vUDV5A6udZom+bn1XHdIWlbSFZgYq7UXCBlwkShhIH3Li/1VxLmr55GOQFQ12WSg==" + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "node_modules/babel-loader": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", + "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", + "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.4.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", + "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.3", + "core-js-compat": "^3.33.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", + "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/blueimp-canvas-to-blob": { + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz", + "integrity": "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==" + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001565", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz", + "integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/compression/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "node_modules/core-js-compat": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.3.tgz", + "integrity": "sha512-cNzGqFsh3Ot+529GIXacjTJ7kegdt5fPXxCBVS1G0iaZpuo/tBz399ymceLJveQhFFZ8qThHiP3fzuoQjKN2ow==", + "dev": true, + "dependencies": { + "browserslist": "^4.22.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/css-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.600", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.600.tgz", + "integrity": "sha512-KD6CWjf1BnQG+NsXuyiTDDT1eV13sKuYsOUioXkQweYTQIbgHkXPry9K7M+7cKtYHnSUPitVaLrXYB1jTkkYrw==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exif-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/exif-js/-/exif-js-2.3.0.tgz", + "integrity": "sha512-1Og9pAzG2FZRVlaavH8bB8BTeHcjMdJhKmeQITkX+uLRCD0xPtKAdZ2clZmQdJ56p9adXtJ8+jwrGp/4505lYg==" + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", + "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fuzzysort": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.9.0.tgz", + "integrity": "sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==" + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gif.js.optimized": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", + "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==" + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermite-resize": { + "version": "2.2.10", + "resolved": "git+ssh://git@github.com/viliusle/Hermite-resize.git#fae53290d2b03520a6fc81d734c3028902a599c0", + "license": "MIT" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", + "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launch-editor": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", + "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "dependencies": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "node_modules/pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "dependencies": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", + "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/style-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", + "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", + "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webfontloader": { + "version": "1.6.28", + "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", + "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==" + }, + "node_modules/webpack": { + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", + "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + } + }, + "@babel/compat-data": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", + "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "dev": true + }, + "@babel/core": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.5.tgz", + "integrity": "sha512-Cwc2XjUrG4ilcfOw4wBAK+enbdgwAcAJCfGUItPBKR7Mjw4aEfAFYrLxeRp4jWgtNIKn3n2AlBOfwwafl+42/g==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.5", + "@babel/parser": "^7.23.5", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + } + }, + "@babel/generator": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.5.tgz", + "integrity": "sha512-BPssCHrBD+0YrxviOa3QzpqwhNIXKEtOa2jQrm4FlmkC2apYgRnQcmPWiGZDlGxiNtltnUFolMe8497Esry+jA==", + "dev": true, + "requires": { + "@babel/types": "^7.23.5", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.15.tgz", + "integrity": "sha512-QkBXwGgaoC2GtGZRoma6kv7Szfv06khvhFav67ZExau2RaXzy8MpHSMO2PNoP2XtmQphJQRHFfg77Bq731Yizw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-create-class-features-plugin": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.23.5.tgz", + "integrity": "sha512-QELlRWxSpgdwdJzSJn4WAhKC+hvw/AtHbbrIoncKHkhKKR/luAlKkgBDcri1EzWAo8f8VvYVryEHN4tax/V67A==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-member-expression-to-functions": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" + } + }, + "@babel/helper-create-regexp-features-plugin": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.15.tgz", + "integrity": "sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + } + }, + "@babel/helper-define-polyfill-provider": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.3.tgz", + "integrity": "sha512-WBrLmuPP47n7PNwsZ57pqam6G/RGo1vw/87b0Blc53tZNGZ4x7YvZ6HgQe2vo1W/FR20OgjeZuGXzudPiXHFug==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-member-expression-to-functions": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.23.0.tgz", + "integrity": "sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==", + "dev": true, + "requires": { + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-optimise-call-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-remap-async-to-generator": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.20.tgz", + "integrity": "sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-wrap-function": "^7.22.20" + } + }, + "@babel/helper-replace-supers": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.20.tgz", + "integrity": "sha512-qsW0In3dbwQUbK8kejJ4R7IHVGwHJlV6lpG6UA7a9hSa2YEiAib+N1T2kr6PEeUT+Fl7najmSOS6SmAwCHK6Tw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-member-expression-to-functions": "^7.22.15", + "@babel/helper-optimise-call-expression": "^7.22.5" + } + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", + "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "dev": true + }, + "@babel/helper-wrap-function": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.20.tgz", + "integrity": "sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==", + "dev": true, + "requires": { + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.15", + "@babel/types": "^7.22.19" + } + }, + "@babel/helpers": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.5.tgz", + "integrity": "sha512-oO7us8FzTEsG3U6ag9MfdF1iA/7Z6dz+MtFhifZk8C8o453rGJFFWUP1t+ULM9TUIAzC9uxXEiXjOiVMyd7QPg==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.5", + "@babel/types": "^7.23.5" + } + }, + "@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.5.tgz", + "integrity": "sha512-hOOqoiNXrmGdFbhgCzu6GiURxUgM27Xwd/aPuu8RfHEZPBzL1Z54okAHAQjXfcQNwvrlkAmAp4SlRTZ45vlthQ==", + "dev": true + }, + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.23.3.tgz", + "integrity": "sha512-iRkKcCqb7iGnq9+3G6rZ+Ciz5VywC4XNRHe57lKM+jOeYAoR0lVqdeeDRfh0tQcTfw/+vBhHn926FmQhLtlFLQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.23.3.tgz", + "integrity": "sha512-WwlxbfMNdVEpQjZmK5mhm7oSwD3dS6eU+Iwsi4Knl9wAletWem7kaRsGOG+8UEbRyqxY4SS5zvtfXwX+jMxUwQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.23.3" + } + }, + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.23.3.tgz", + "integrity": "sha512-XaJak1qcityzrX0/IU5nKHb34VaibwP3saKqG6a/tppelgllOH13LUann4ZCIBcVOeE6H18K4Vx9QKkVww3z/w==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "requires": {} + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.3" + } + }, + "@babel/plugin-syntax-import-assertions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.23.3.tgz", + "integrity": "sha512-lPgDSU+SJLK3xmFDTV2ZRQAiM7UuUjGidwBywFavObCiZc1BeAAcMtHJKUya92hPHO+at63JJPLygilZard8jw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-syntax-import-attributes": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.23.3.tgz", + "integrity": "sha512-pawnE0P9g10xgoP7yKr6CK63K2FMsTE+FZidZO/1PwRdzmAPVs+HS1mAURUsgaoxammTJvULUdIkEK0gOcU2tA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + } + }, + "@babel/plugin-transform-arrow-functions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.23.3.tgz", + "integrity": "sha512-NzQcQrzaQPkaEwoTm4Mhyl8jI1huEL/WWIEvudjTCMJ9aBZNpsJbMASx7EQECtQQPS/DcnFpo0FIh3LvEO9cxQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-async-generator-functions": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.23.4.tgz", + "integrity": "sha512-efdkfPhHYTtn0G6n2ddrESE91fgXxjlqLsnUtPWnJs4a4mZIbUaK7ffqKIIUKXSHwcDvaCVX6GXkaJJFqtX7jw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.20", + "@babel/plugin-syntax-async-generators": "^7.8.4" + } + }, + "@babel/plugin-transform-async-to-generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.23.3.tgz", + "integrity": "sha512-A7LFsKi4U4fomjqXJlZg/u0ft/n8/7n7lpffUP/ZULx/DtV9SGlNKZolHH6PE8Xl1ngCc0M11OaeZptXVkfKSw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.20" + } + }, + "@babel/plugin-transform-block-scoped-functions": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.23.3.tgz", + "integrity": "sha512-vI+0sIaPIO6CNuM9Kk5VmXcMVRiOpDh7w2zZt9GXzmE/9KD70CUEVhvPR/etAeNK/FAEkhxQtXOzVF3EuRL41A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-block-scoping": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.23.4.tgz", + "integrity": "sha512-0QqbP6B6HOh7/8iNR4CQU2Th/bbRtBp4KS9vcaZd1fZ0wSh5Fyssg0UCIHwxh+ka+pNDREbVLQnHCMHKZfPwfw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-class-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.23.3.tgz", + "integrity": "sha512-uM+AN8yCIjDPccsKGlw271xjJtGii+xQIF/uMPS8H15L12jZTsLfF4o5vNO7d/oUguOyfdikHGc/yi9ge4SGIg==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-class-static-block": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.23.4.tgz", + "integrity": "sha512-nsWu/1M+ggti1SOALj3hfx5FXzAY06fwPJsUZD4/A5e1bWi46VUIWtD+kOX6/IdhXGsXBWllLFDSnqSCdUNydQ==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + } + }, + "@babel/plugin-transform-classes": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.23.5.tgz", + "integrity": "sha512-jvOTR4nicqYC9yzOHIhXG5emiFEOpappSJAl73SDSEDcybD+Puuze8Tnpb9p9qEyYup24tq891gkaygIFvWDqg==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20", + "@babel/helper-split-export-declaration": "^7.22.6", + "globals": "^11.1.0" + } + }, + "@babel/plugin-transform-computed-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.23.3.tgz", + "integrity": "sha512-dTj83UVTLw/+nbiHqQSFdwO9CbTtwq1DsDqm3CUEtDrZNET5rT5E6bIdTlOftDTDLMYxvxHNEYO4B9SLl8SLZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/template": "^7.22.15" + } + }, + "@babel/plugin-transform-destructuring": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.23.3.tgz", + "integrity": "sha512-n225npDqjDIr967cMScVKHXJs7rout1q+tt50inyBCPkyZ8KxeI6d+GIbSBTT/w/9WdlWDOej3V9HE5Lgk57gw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-dotall-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.23.3.tgz", + "integrity": "sha512-vgnFYDHAKzFaTVp+mneDsIEbnJ2Np/9ng9iviHw3P/KVcgONxpNULEW/51Z/BaFojG2GI2GwwXck5uV1+1NOYQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-duplicate-keys": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.23.3.tgz", + "integrity": "sha512-RrqQ+BQmU3Oyav3J+7/myfvRCq7Tbz+kKLLshUmMwNlDHExbGL7ARhajvoBJEvc+fCguPPu887N+3RRXBVKZUA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-dynamic-import": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.23.4.tgz", + "integrity": "sha512-V6jIbLhdJK86MaLh4Jpghi8ho5fGzt3imHOBu/x0jlBaPYqDoWz4RDXjmMOfnh+JWNaQleEAByZLV0QzBT4YQQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + } + }, + "@babel/plugin-transform-exponentiation-operator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.23.3.tgz", + "integrity": "sha512-5fhCsl1odX96u7ILKHBj4/Y8vipoqwsJMh4csSA8qFfxrZDEA4Ssku2DyNvMJSmZNOEBT750LfFPbtrnTP90BQ==", + "dev": true, + "requires": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-export-namespace-from": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.23.4.tgz", + "integrity": "sha512-GzuSBcKkx62dGzZI1WVgTWvkkz84FZO5TC5T8dl/Tht/rAla6Dg/Mz9Yhypg+ezVACf/rgDuQt3kbWEv7LdUDQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + } + }, + "@babel/plugin-transform-for-of": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.23.3.tgz", + "integrity": "sha512-X8jSm8X1CMwxmK878qsUGJRmbysKNbdpTv/O1/v0LuY/ZkZrng5WYiekYSdg9m09OTmDDUWeEDsTE+17WYbAZw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-function-name": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.23.3.tgz", + "integrity": "sha512-I1QXp1LxIvt8yLaib49dRW5Okt7Q4oaxao6tFVKS/anCdEOMtYwWVKoiOA1p34GOWIZjUK0E+zCp7+l1pfQyiw==", + "dev": true, + "requires": { + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-json-strings": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.23.4.tgz", + "integrity": "sha512-81nTOqM1dMwZ/aRXQ59zVubN9wHGqk6UtqRK+/q+ciXmRy8fSolhGVvG09HHRGo4l6fr/c4ZhXUQH0uFW7PZbg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-json-strings": "^7.8.3" + } + }, + "@babel/plugin-transform-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.23.3.tgz", + "integrity": "sha512-wZ0PIXRxnwZvl9AYpqNUxpZ5BiTGrYt7kueGQ+N5FiQ7RCOD4cm8iShd6S6ggfVIWaJf2EMk8eRzAh52RfP4rQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-logical-assignment-operators": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.23.4.tgz", + "integrity": "sha512-Mc/ALf1rmZTP4JKKEhUwiORU+vcfarFVLfcFiolKUo6sewoxSEgl36ak5t+4WamRsNr6nzjZXQjM35WsU+9vbg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + } + }, + "@babel/plugin-transform-member-expression-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.23.3.tgz", + "integrity": "sha512-sC3LdDBDi5x96LA+Ytekz2ZPk8i/Ck+DEuDbRAll5rknJ5XRTSaPKEYwomLcs1AA8wg9b3KjIQRsnApj+q51Ag==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-modules-amd": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.23.3.tgz", + "integrity": "sha512-vJYQGxeKM4t8hYCKVBlZX/gtIY2I7mRGFNcm85sgXGMTBcoV3QdVtdpbcWEbzbfUIUZKwvgFT82mRvaQIebZzw==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-modules-commonjs": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.23.3.tgz", + "integrity": "sha512-aVS0F65LKsdNOtcz6FRCpE4OgsP2OFnW46qNxNIX9h3wuzaNcSQsJysuMwqSibC98HPrf2vCgtxKNwS0DAlgcA==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5" + } + }, + "@babel/plugin-transform-modules-systemjs": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.23.3.tgz", + "integrity": "sha512-ZxyKGTkF9xT9YJuKQRo19ewf3pXpopuYQd8cDXqNzc3mUNbOME0RKMoZxviQk74hwzfQsEe66dE92MaZbdHKNQ==", + "dev": true, + "requires": { + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/plugin-transform-modules-umd": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.23.3.tgz", + "integrity": "sha512-zHsy9iXX2nIsCBFPud3jKn1IRPWg3Ing1qOZgeKV39m1ZgIdpJqvlWVeiHBZC6ITRG0MfskhYe9cLgntfSFPIg==", + "dev": true, + "requires": { + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-new-target": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.23.3.tgz", + "integrity": "sha512-YJ3xKqtJMAT5/TIZnpAR3I+K+WaDowYbN3xyxI8zxx/Gsypwf9B9h0VB+1Nh6ACAAPRS5NSRje0uVv5i79HYGQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.23.4.tgz", + "integrity": "sha512-jHE9EVVqHKAQx+VePv5LLGHjmHSJR76vawFPTdlxR/LVJPfOEGxREQwQfjuZEOPTwG92X3LINSh3M40Rv4zpVA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + } + }, + "@babel/plugin-transform-numeric-separator": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.23.4.tgz", + "integrity": "sha512-mps6auzgwjRrwKEZA05cOwuDc9FAzoyFS4ZsG/8F43bTLf/TgkJg7QXOrPO1JO599iA3qgK9MXdMGOEC8O1h6Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + } + }, + "@babel/plugin-transform-object-rest-spread": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.23.4.tgz", + "integrity": "sha512-9x9K1YyeQVw0iOXJlIzwm8ltobIIv7j2iLyP2jIhEbqPRQ7ScNgwQufU2I0Gq11VjyG4gI4yMXt2VFags+1N3g==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.23.3" + } + }, + "@babel/plugin-transform-object-super": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.23.3.tgz", + "integrity": "sha512-BwQ8q0x2JG+3lxCVFohg+KbQM7plfpBwThdW9A6TMtWwLsbDA01Ek2Zb/AgDN39BiZsExm4qrXxjk+P1/fzGrA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.20" + } + }, + "@babel/plugin-transform-optional-catch-binding": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.23.4.tgz", + "integrity": "sha512-XIq8t0rJPHf6Wvmbn9nFxU6ao4c7WhghTR5WyV8SrJfUFzyxhCm4nhC+iAp3HFhbAKLfYpgzhJ6t4XCtVwqO5A==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + } + }, + "@babel/plugin-transform-optional-chaining": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.23.4.tgz", + "integrity": "sha512-ZU8y5zWOfjM5vZ+asjgAPwDaBjJzgufjES89Rs4Lpq63O300R/kOz30WCLo6BxxX6QVEilwSlpClnG5cZaikTA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + } + }, + "@babel/plugin-transform-parameters": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.23.3.tgz", + "integrity": "sha512-09lMt6UsUb3/34BbECKVbVwrT9bO6lILWln237z7sLaWnMsTi7Yc9fhX5DLpkJzAGfaReXI22wP41SZmnAA3Vw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-private-methods": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.23.3.tgz", + "integrity": "sha512-UzqRcRtWsDMTLrRWFvUBDwmw06tCQH9Rl1uAjfh6ijMSmGYQ+fpdB+cnqRC8EMh5tuuxSv0/TejGL+7vyj+50g==", + "dev": true, + "requires": { + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-private-property-in-object": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.23.4.tgz", + "integrity": "sha512-9G3K1YqTq3F4Vt88Djx1UZ79PDyj+yKRnUy7cZGSMe+a7jkwD259uKKuUzQlPkGam7R+8RJwh5z4xO27fA1o2A==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + } + }, + "@babel/plugin-transform-property-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.23.3.tgz", + "integrity": "sha512-jR3Jn3y7cZp4oEWPFAlRsSWjxKe4PZILGBSd4nis1TsC5qeSpb+nrtihJuDhNI7QHiVbUaiXa0X2RZY3/TI6Nw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-regenerator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.23.3.tgz", + "integrity": "sha512-KP+75h0KghBMcVpuKisx3XTu9Ncut8Q8TuvGO4IhY+9D5DFEckQefOuIsB/gQ2tG71lCke4NMrtIPS8pOj18BQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "regenerator-transform": "^0.15.2" + } + }, + "@babel/plugin-transform-reserved-words": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.23.3.tgz", + "integrity": "sha512-QnNTazY54YqgGxwIexMZva9gqbPa15t/x9VS+0fsEFWplwVpXYZivtgl43Z1vMpc1bdPP2PP8siFeVcnFvA3Cg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-runtime": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.23.4.tgz", + "integrity": "sha512-ITwqpb6V4btwUG0YJR82o2QvmWrLgDnx/p2A3CTPYGaRgULkDiC0DRA2C4jlRB9uXGUEfaSS/IGHfVW+ohzYDw==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", + "semver": "^6.3.1" + } + }, + "@babel/plugin-transform-shorthand-properties": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.23.3.tgz", + "integrity": "sha512-ED2fgqZLmexWiN+YNFX26fx4gh5qHDhn1O2gvEhreLW2iI63Sqm4llRLCXALKrCnbN4Jy0VcMQZl/SAzqug/jg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-spread": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.23.3.tgz", + "integrity": "sha512-VvfVYlrlBVu+77xVTOAoxQ6mZbnIq5FM0aGBSFEcIh03qHf+zNqA4DC/3XMUozTg7bZV3e3mZQ0i13VB6v5yUg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + } + }, + "@babel/plugin-transform-sticky-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.23.3.tgz", + "integrity": "sha512-HZOyN9g+rtvnOU3Yh7kSxXrKbzgrm5X4GncPY1QOquu7epga5MxKHVpYu2hvQnry/H+JjckSYRb93iNfsioAGg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-template-literals": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.23.3.tgz", + "integrity": "sha512-Flok06AYNp7GV2oJPZZcP9vZdszev6vPBkHLwxwSpaIqx75wn6mUd3UFWsSsA0l8nXAKkyCmL/sR02m8RYGeHg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-typeof-symbol": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.23.3.tgz", + "integrity": "sha512-4t15ViVnaFdrPC74be1gXBSMzXk3B4Us9lP7uLRQHTFpV5Dvt33pn+2MyyNxmN3VTTm3oTrZVMUmuw3oBnQ2oQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-unicode-escapes": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.23.3.tgz", + "integrity": "sha512-OMCUx/bU6ChE3r4+ZdylEqAjaQgHAgipgW8nsCfu5pGqDcFytVd91AwRvUJSBZDz0exPGgnjoqhgRYLRjFZc9Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-unicode-property-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.23.3.tgz", + "integrity": "sha512-KcLIm+pDZkWZQAFJ9pdfmh89EwVfmNovFBcXko8szpBeF8z68kWIPeKlmSOkT9BXJxs2C0uk+5LxoxIv62MROA==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-unicode-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.23.3.tgz", + "integrity": "sha512-wMHpNA4x2cIA32b/ci3AfwNgheiva2W0WUKWTK7vBHBhDKfPsc5cFGNWm69WBqpwd86u1qwZ9PWevKqm1A3yAw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-transform-unicode-sets-regex": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.23.3.tgz", + "integrity": "sha512-W7lliA/v9bNR83Qc3q1ip9CQMZ09CcHDbHfbLRDNuAhn1Mvkr1ZNF7hPmztMQvtTGVLJ9m8IZqWsTkXOml8dbw==", + "dev": true, + "requires": { + "@babel/helper-create-regexp-features-plugin": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/preset-env": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.23.5.tgz", + "integrity": "sha512-0d/uxVD6tFGWXGDSfyMD1p2otoaKmu6+GD+NfAx0tMaH+dxORnp7T9TaVQ6mKyya7iBtCIVxHjWT7MuzzM9z+A==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.23.5", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.23.5", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.23.3", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.23.3", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.23.3", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.23.3", + "@babel/plugin-syntax-import-attributes": "^7.23.3", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.23.3", + "@babel/plugin-transform-async-generator-functions": "^7.23.4", + "@babel/plugin-transform-async-to-generator": "^7.23.3", + "@babel/plugin-transform-block-scoped-functions": "^7.23.3", + "@babel/plugin-transform-block-scoping": "^7.23.4", + "@babel/plugin-transform-class-properties": "^7.23.3", + "@babel/plugin-transform-class-static-block": "^7.23.4", + "@babel/plugin-transform-classes": "^7.23.5", + "@babel/plugin-transform-computed-properties": "^7.23.3", + "@babel/plugin-transform-destructuring": "^7.23.3", + "@babel/plugin-transform-dotall-regex": "^7.23.3", + "@babel/plugin-transform-duplicate-keys": "^7.23.3", + "@babel/plugin-transform-dynamic-import": "^7.23.4", + "@babel/plugin-transform-exponentiation-operator": "^7.23.3", + "@babel/plugin-transform-export-namespace-from": "^7.23.4", + "@babel/plugin-transform-for-of": "^7.23.3", + "@babel/plugin-transform-function-name": "^7.23.3", + "@babel/plugin-transform-json-strings": "^7.23.4", + "@babel/plugin-transform-literals": "^7.23.3", + "@babel/plugin-transform-logical-assignment-operators": "^7.23.4", + "@babel/plugin-transform-member-expression-literals": "^7.23.3", + "@babel/plugin-transform-modules-amd": "^7.23.3", + "@babel/plugin-transform-modules-commonjs": "^7.23.3", + "@babel/plugin-transform-modules-systemjs": "^7.23.3", + "@babel/plugin-transform-modules-umd": "^7.23.3", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.23.3", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.23.4", + "@babel/plugin-transform-numeric-separator": "^7.23.4", + "@babel/plugin-transform-object-rest-spread": "^7.23.4", + "@babel/plugin-transform-object-super": "^7.23.3", + "@babel/plugin-transform-optional-catch-binding": "^7.23.4", + "@babel/plugin-transform-optional-chaining": "^7.23.4", + "@babel/plugin-transform-parameters": "^7.23.3", + "@babel/plugin-transform-private-methods": "^7.23.3", + "@babel/plugin-transform-private-property-in-object": "^7.23.4", + "@babel/plugin-transform-property-literals": "^7.23.3", + "@babel/plugin-transform-regenerator": "^7.23.3", + "@babel/plugin-transform-reserved-words": "^7.23.3", + "@babel/plugin-transform-shorthand-properties": "^7.23.3", + "@babel/plugin-transform-spread": "^7.23.3", + "@babel/plugin-transform-sticky-regex": "^7.23.3", + "@babel/plugin-transform-template-literals": "^7.23.3", + "@babel/plugin-transform-typeof-symbol": "^7.23.3", + "@babel/plugin-transform-unicode-escapes": "^7.23.3", + "@babel/plugin-transform-unicode-property-regex": "^7.23.3", + "@babel/plugin-transform-unicode-regex": "^7.23.3", + "@babel/plugin-transform-unicode-sets-regex": "^7.23.3", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.6", + "babel-plugin-polyfill-corejs3": "^0.8.5", + "babel-plugin-polyfill-regenerator": "^0.5.3", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" + } + }, + "@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + } + }, + "@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "@babel/runtime": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz", + "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==", + "requires": { + "regenerator-runtime": "^0.14.0" + } + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.5.tgz", + "integrity": "sha512-czx7Xy5a6sapWWRx61m1Ke1Ra4vczu1mCTtJam5zRTBOonfdJ+S/B6HYmGYu3fJtr8GGET3si6IhgWVBhJ/m8w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.23.5", + "@babel/generator": "^7.23.5", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.5", + "@babel/types": "^7.23.5", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.5.tgz", + "integrity": "sha512-ON5kSOJwVO6xXVRTvOI0eOnWe7VdUcIpsovGo9U/Br4Ie4UVFQTboO2cYnDhAGU6Fp+UxSiT+pMft0SMHfuq6w==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "@types/bonjour": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/connect-history-api-fallback": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "dev": true, + "requires": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "@types/eslint": { + "version": "8.44.8", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.8.tgz", + "integrity": "sha512-4K8GavROwhrYl2QXDXm0Rv9epkA8GBFu0EI+XrrnnuCl7u8CWBRusX7fXJfanhZTDWSAL24gDI/UqXyUM0Injw==", + "dev": true, + "requires": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "requires": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "@types/express-serve-static-core": { + "version": "4.17.41", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.41.tgz", + "integrity": "sha512-OaJ7XLaelTgrvlZD8/aa0vvvxZdUmlCn6MtWeB7TkiKW70BQLc9XEPpDLPdbo52ZhXUCrznlWdCHWxJWtdyajA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true + }, + "@types/http-proxy": { + "version": "1.17.14", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.14.tgz", + "integrity": "sha512-SSrD0c1OQzlFX7pGu1eXxSEjemej64aaNPRhhVYUGqXh0BtldAAx37MG8btcumvpgKyZp1F5Gn3JkktdxiFv6w==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "@types/node": { + "version": "20.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.1.tgz", + "integrity": "sha512-T2qwhjWwGH81vUEx4EXmBKsTJRXFXNZTL4v0gi01+zyBmCwzE6TyHszqX01m+QHTEq+EZNo13NeJIdEqf+Myrg==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } + }, + "@types/node-forge": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.10.tgz", + "integrity": "sha512-y6PJDYN4xYBxwd22l+OVH35N+1fCYWiuC3aiP2SlXVE6Lo7SS+rSx9r89hLxrP4pn6n1lBGhHJ12pj3F3Mpttw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/qs": { + "version": "6.9.10", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.10.tgz", + "integrity": "sha512-3Gnx08Ns1sEoCrWssEgTSJs/rsT2vhGP+Ja9cnnk9k4ALxinORlQneLXFeFKOTJMOeZUFD1s7w+w2AphTpvzZw==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "@types/serve-index": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, + "@types/serve-static": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", + "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "@types/sockjs": { + "version": "0.3.36", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "requires": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "requires": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "requires": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "requires": { + "@xtuc/long": "4.2.2" + } + }, + "@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "requires": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "requires": {} + }, + "@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "requires": { + "envinfo": "^7.7.3" + } + }, + "@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "requires": {} + }, + "@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "acorn": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", + "integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==", + "dev": true + }, + "acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "requires": {} + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } + } + }, + "ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "requires": {} + }, + "alertifyjs": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/alertifyjs/-/alertifyjs-1.13.1.tgz", + "integrity": "sha512-CckZE2dZDsEEXglOXKxT00vUDV5A6udZom+bn1XHdIWlbSFZgYq7UXCBlwkShhIH3Li/1VxLmr55GOQFQ12WSg==" + }, + "ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "babel-loader": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", + "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dev": true, + "requires": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + } + }, + "babel-plugin-polyfill-corejs2": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.6.tgz", + "integrity": "sha512-jhHiWVZIlnPbEUKSSNb9YoWcQGdlTLq7z1GHL4AjFxaoOUMuuEVJ+Y4pAaQUGOGk93YsVCKPbqbfw3m0SM6H8Q==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.4.3", + "semver": "^6.3.1" + } + }, + "babel-plugin-polyfill-corejs3": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.6.tgz", + "integrity": "sha512-leDIc4l4tUgU7str5BWLS2h8q2N4Nf6lGZP6UrNDxdtfF2g69eJ5L0H7S8A5Ln/arfFAfHor5InAdZuIOwZdgQ==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.4.3", + "core-js-compat": "^3.33.1" + } + }, + "babel-plugin-polyfill-regenerator": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.3.tgz", + "integrity": "sha512-8sHeDOmXC8csczMrYEOf0UTNa4yE2SxV5JGeT/LP1n0OYVDUUFPxG9vdk2AlDlIit4t+Kf0xCtpgXPBwnn/9pw==", + "dev": true, + "requires": { + "@babel/helper-define-polyfill-provider": "^0.4.3" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "blueimp-canvas-to-blob": { + "version": "3.29.0", + "resolved": "https://registry.npmjs.org/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz", + "integrity": "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==" + }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, + "requires": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true + }, + "call-bind": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", + "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.1", + "set-function-length": "^1.1.1" + } + }, + "caniuse-lite": { + "version": "1.0.30001565", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001565.tgz", + "integrity": "sha512-xrE//a3O7TP0vaJ8ikzkD2c2NgcVUvsEe2IvFTntV4Yd1Z9FVzh+gW+enX96L0psrbaFMcVcH2l90xNuGDWc8w==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true + }, + "clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "requires": { + "mime-db": ">= 1.43.0 < 2" + } + }, + "compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "requires": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + } + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true + }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true + }, + "core-js-compat": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.33.3.tgz", + "integrity": "sha512-cNzGqFsh3Ot+529GIXacjTJ7kegdt5fPXxCBVS1G0iaZpuo/tBz399ymceLJveQhFFZ8qThHiP3fzuoQjKN2ow==", + "dev": true, + "requires": { + "browserslist": "^4.22.1" + } + }, + "core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, + "css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "requires": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } + }, + "cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "requires": { + "execa": "^5.0.0" + } + }, + "define-data-property": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", + "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true + }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "dev": true, + "requires": { + "@leichtgewicht/ip-codec": "^2.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "electron-to-chromium": { + "version": "1.4.600", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.600.tgz", + "integrity": "sha512-KD6CWjf1BnQG+NsXuyiTDDT1eV13sKuYsOUioXkQweYTQIbgHkXPry9K7M+7cKtYHnSUPitVaLrXYB1jTkkYrw==", + "dev": true + }, + "emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true + }, + "enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + } + }, + "envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true + }, + "es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "requires": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + } + }, + "esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "requires": { + "estraverse": "^5.2.0" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + } + } + }, + "estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true + }, + "esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true + }, + "eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true + }, + "events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true + }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "exif-js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/exif-js/-/exif-js-2.3.0.tgz", + "integrity": "sha512-1Og9pAzG2FZRVlaavH8bB8BTeHcjMdJhKmeQITkX+uLRCD0xPtKAdZ2clZmQdJ56p9adXtJ8+jwrGp/4505lYg==" + }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true + }, + "faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "requires": { + "websocket-driver": ">=0.5.1" + } + }, + "file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, + "follow-redirects": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz", + "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==", + "dev": true + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true + }, + "fs-monkey": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", + "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true + }, + "fuzzysort": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.9.0.tgz", + "integrity": "sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==" + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "get-intrinsic": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", + "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + } + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, + "gif.js.optimized": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gif.js.optimized/-/gif.js.optimized-1.0.1.tgz", + "integrity": "sha512-IS0F42Xken6lp/iR4irgG4r52tvxRkEKsXGZmlUHUOb00SWNMezJOJwkVaJk2MLW53rqzMbPnnBtEhs9hcMJ9w==" + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "glur": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glur/-/glur-1.1.2.tgz", + "integrity": "sha512-l+8esYHTKOx2G/Aao4lEQ0bnHWg4fWtJbVoZZT9Knxi01pB8C80BR85nONLFwkkQoFRCmXY+BUcGZN3yZ2QsRA==" + }, + "gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "requires": { + "get-intrinsic": "^1.1.3" + } + }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dev": true, + "requires": { + "get-intrinsic": "^1.2.2" + } + }, + "has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true + }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "hermite-resize": { + "version": "git+ssh://git@github.com/viliusle/Hermite-resize.git#fae53290d2b03520a6fc81d734c3028902a599c0", + "from": "hermite-resize@git+https://github.com/viliusle/Hermite-resize.git" + }, + "hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "html-entities": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", + "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "dev": true + }, + "http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "requires": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + } + }, + "http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "requires": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + } + }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, + "icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "requires": {} + }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "interpret": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", + "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", + "dev": true + }, + "ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "dev": true + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, + "is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "requires": { + "is-docker": "^2.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true + }, + "jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "requires": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true + }, + "launch-editor": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.1.tgz", + "integrity": "sha512-eB/uXmFVpY4zezmGp5XtU21kwo7GBbKB+EQ+UZeWtGb9yAM5xt/Evk+lYH3eRNAtId+ej4u7TYPFZ07w4s7rRw==", + "dev": true, + "requires": { + "picocolors": "^1.0.0", + "shell-quote": "^1.8.1" + } + }, + "loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true + }, + "loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "requires": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true + }, + "memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.4" + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true + }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "requires": { + "mime-db": "1.52.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "requires": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + } + }, + "multimath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/multimath/-/multimath-2.0.0.tgz", + "integrity": "sha512-toRx66cAMJ+Ccz7pMIg38xSIrtnbozk0dchXezwQDMgQmbGpfxjtv68H+L00iFL8hxDaVjrmwAFSb3I6bg8Q2g==", + "requires": { + "glur": "^1.1.2", + "object-assign": "^4.1.1" + } + }, + "nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true + }, + "neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true + }, + "node-releases": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", + "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "dev": true + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true + }, + "obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "dev": true + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "requires": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "requires": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true + }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "pica": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/pica/-/pica-7.1.1.tgz", + "integrity": "sha512-WY73tMvNzXWEld2LicT9Y260L43isrZ85tPuqRyvtkljSDLmnNFQmZICt4xUJMVulmcc6L9O7jbBrtx3DOz/YQ==", + "requires": { + "glur": "^1.1.2", + "inherits": "^2.0.3", + "multimath": "^2.0.0", + "object-assign": "^4.1.1", + "webworkify": "^1.5.0" + } + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "requires": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "requires": {} + }, + "postcss-modules-local-by-default": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + } + }, + "postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "requires": { + "postcss-selector-parser": "^6.0.4" + } + }, + "postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "requires": { + "icss-utils": "^5.0.0" + } + }, + "postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + } + }, + "postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "dependencies": { + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true + } + } + }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "requires": { + "side-channel": "^1.0.4" + } + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "dependencies": { + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + } + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "rechoir": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.1.tgz", + "integrity": "sha512-/njmZ8s1wVeR6pjTZ+0nCnv8SpZNRMT2D1RLOJQESlYFDBvwpTA4KWJpZ+sBJ4+vhjILRcK7JIFdGCdxEAAitg==", + "dev": true, + "requires": { + "resolve": "^1.9.0" + } + }, + "regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "regenerate-unicode-properties": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", + "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "dev": true, + "requires": { + "regenerate": "^1.4.2" + } + }, + "regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.4" + } + }, + "regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "requires": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + } + }, + "regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true + } + } + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + } + }, + "select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "dev": true, + "requires": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==" + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + }, + "dependencies": { + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, + "serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, + "serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "requires": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true + } + } + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "set-function-length": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", + "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", + "dev": true, + "requires": { + "define-data-property": "^1.1.1", + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + } + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, + "shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + }, + "sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "requires": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, + "source-map-loader": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-3.0.2.tgz", + "integrity": "sha512-BokxPoLjyl3iOrgkWaakaxqnelAJSS+0V+De0kKIq6lyWrXuiPgYTGp6z3iHmqljKAaLXwZa+ctD8GccRJeVvg==", + "dev": true, + "requires": { + "abab": "^2.0.5", + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.1" + } + }, + "source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + } + }, + "spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "requires": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true + }, + "terser": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", + "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", + "dev": true, + "requires": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + } + }, + "terser-webpack-plugin": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", + "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true + }, + "unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "requires": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + } + }, + "unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true + }, + "unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true + }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true + }, + "watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "requires": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + } + }, + "wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "requires": { + "minimalistic-assert": "^1.0.0" + } + }, + "webfontloader": { + "version": "1.6.28", + "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", + "integrity": "sha512-Egb0oFEga6f+nSgasH3E0M405Pzn6y3/9tOVanv/DLfa1YBIgcv90L18YyWnvXkRbIM17v5Kv6IT2N6g1x5tvQ==" + }, + "webpack": { + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "dev": true, + "requires": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "dependencies": { + "schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + } + } + } + }, + "webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "dev": true, + "requires": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "dependencies": { + "commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true + } + } + }, + "webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "requires": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + } + } + }, + "webpack-dev-server": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", + "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "dev": true, + "requires": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "dependencies": { + "ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + } + } + } + }, + "webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "requires": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + } + }, + "webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true + }, + "websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "requires": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + } + }, + "websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true + }, + "webworkify": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/webworkify/-/webworkify-1.5.0.tgz", + "integrity": "sha512-AMcUeyXAhbACL8S2hqqdqOLqvJ8ylmIbNwUIqQujRSouf4+eUFaXbG6F1Rbu+srlJMmxQWsiU7mOJi0nMBfM1g==" + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "ws": { + "version": "8.14.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz", + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "dev": true, + "requires": {} + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } +} diff --git a/paintplus/frontend/package.json b/paintplus/frontend/package.json new file mode 100644 index 0000000..5282082 --- /dev/null +++ b/paintplus/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "miniPaint", + "version": "4.14.2", + "author": "Vilius L.", + "description": "Online graphics editing tool lets create, edit images using HTML5 technologies.", + "keywords": [ + "canvas", + "drawing", + "paint", + "layers", + "effects" + ], + "scripts": { + "server": "webpack serve --mode development --env development --open", + "dev": "webpack --mode development", + "build": "webpack --mode production" + }, + "repository": { + "type": "git", + "url": "https://github.com/viliusle/miniPaint" + }, + "homepage": "https://github.com/viliusle/miniPaint", + "license": "MIT", + "devDependencies": { + "@babel/core": "^7.14.5", + "@babel/plugin-transform-runtime": "^7.14.5", + "@babel/preset-env": "^7.14.5", + "babel-loader": "^8.2.2", + "css-loader": "^5.2.6", + "source-map-loader": "^3.0.0", + "style-loader": "^2.0.0", + "webpack": "^5.76.0", + "webpack-cli": "^4.7.2", + "webpack-dev-server": "^4.3.1" + }, + "dependencies": { + "@babel/runtime": "^7.14.5", + "alertifyjs": "^1.13.1", + "blueimp-canvas-to-blob": "^3.28.0", + "exif-js": "^2.3.0", + "file-saver": "^2.0.5", + "fuzzysort": "^1.1.4", + "gif.js.optimized": "^1.0.1", + "hermite-resize": "git+https://github.com/viliusle/Hermite-resize.git", + "jquery": "^3.5.1", + "pica": "^7.0.0", + "semver-compare": "^1.0.0", + "uuid": "^8.3.2", + "webfontloader": "^1.6.28" + } +} diff --git a/paintplus/frontend/service-worker.js b/paintplus/frontend/service-worker.js new file mode 100644 index 0000000..74b64dc --- /dev/null +++ b/paintplus/frontend/service-worker.js @@ -0,0 +1,68 @@ + +//IMPORTANT - this file is not used !!! + + +// use a cacheName for cache versioning +var cacheName = 'v1:static'; + +// during the install phase you usually want to cache static assets +self.addEventListener('install', function(e) { + // once the SW is installed, go ahead and fetch the resources to make this work offline + e.waitUntil( + caches.open(cacheName).then(function(cache) { + return cache.addAll([ + './', + './dist/bundle.js', + './images/favicon.png', + './images/logo.svg', + './images/logo-colors.png', + './images/icons/animation.svg', + './images/icons/blur.svg', + './images/icons/bold.svg', + './images/icons/brush.svg', + './images/icons/bulge_pinch.svg', + './images/icons/clone.svg', + './images/icons/crop.svg', + './images/icons/delete.svg', + './images/icons/desaturate.svg', + './images/icons/erase.svg', + './images/icons/external.png', + './images/icons/fill.svg', + './images/icons/gradient.png', + './images/icons/grid.png', + './images/icons/italic.svg', + './images/icons/magic_erase.svg', + './images/icons/media.svg', + './images/icons/menu.svg', + './images/icons/pencil.svg', + './images/icons/pick_color.svg', + './images/icons/refresh.svg', + './images/icons/select.svg', + './images/icons/selection.svg', + './images/icons/shape.svg', + './images/icons/sharpen.svg', + './images/icons/strikethrough.svg', + './images/icons/text.svg', + './images/icons/underline.svg', + './images/icons/view.svg' + ]).then(function() { + self.skipWaiting(); + }); + }) + ); +}); + +// when the browser fetches a url +self.addEventListener('fetch', function(event) { + // either respond with the cached object or go ahead and fetch the actual url + event.respondWith( + caches.match(event.request).then(function(response) { + if (response) { + // retrieve from cache + return response; + } + // fetch as normal + return fetch(event.request); + }) + ); +}); diff --git a/paintplus/frontend/src/components/ImageCanvas.css b/paintplus/frontend/src/components/ImageCanvas.css new file mode 100644 index 0000000..3259a07 --- /dev/null +++ b/paintplus/frontend/src/components/ImageCanvas.css @@ -0,0 +1,133 @@ +/* ImageCanvas fills the entire canvas wrapper */ +.canvas-container { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.canvas-container canvas { + display: block; +} + +.canvas-controls { + position: absolute; + bottom: 10px; + left: 10px; + right: 10px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 10; + pointer-events: none; +} + +.canvas-controls > * { + pointer-events: auto; +} + +.clear-selection-btn { + background-color: #ff4444; + color: white; + padding: 8px 16px; + border-radius: 4px; +} + +.clear-selection-btn:hover { + background-color: #cc0000; +} + +.selection-hint { + position: absolute; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.8); + color: #0088ff; + padding: 6px 12px; + border-radius: 4px; + font-size: 11px; + z-index: 10; + white-space: nowrap; + pointer-events: none; +} + +/* Clear selection button */ +.clear-selection-btn { + position: absolute; + top: 8px; + right: 8px; + background-color: #cc3333; + color: white; + padding: 6px 12px; + font-size: 11px; + border: none; + border-radius: 3px; + cursor: pointer; + z-index: 10; +} + +.clear-selection-btn:hover { + background-color: #dd4444; +} + +/* Zoom controls */ +.zoom-controls { + display: flex; + align-items: center; + gap: 4px; + background-color: rgba(0, 0, 0, 0.8); + padding: 6px 10px; + border-radius: 4px; +} + +.zoom-controls button { + width: 28px; + height: 28px; + padding: 0; + font-size: 18px; + font-weight: bold; + background-color: #444; + color: white; + border: 1px solid #666; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.zoom-controls button:hover { + background-color: #555; +} + +.zoom-level { + color: white; + font-size: 12px; + min-width: 45px; + text-align: center; +} + +/* Tool mode indicator */ +.tool-mode-indicator { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 120, 255, 0.9); + color: white; + padding: 10px 20px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + z-index: 10; + white-space: nowrap; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} diff --git a/paintplus/frontend/src/components/ImageCanvas.jsx b/paintplus/frontend/src/components/ImageCanvas.jsx new file mode 100644 index 0000000..ff3a324 --- /dev/null +++ b/paintplus/frontend/src/components/ImageCanvas.jsx @@ -0,0 +1,752 @@ +import React, { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { fabric } from 'fabric'; +import './ImageCanvas.css'; + +const ImageCanvas = forwardRef(({ + imageUrl, + onSelectionChange, + selectionMode, + advancedToolMode, + onAdvancedToolClick, + zoom = 100, + onZoomChange, + externalSelection, + isProcessing +}, ref) => { + const canvasRef = useRef(null); + const fabricCanvasRef = useRef(null); + const [currentSelection, setCurrentSelection] = useState(null); + const [currentZoom, setCurrentZoom] = useState(1); + const currentSelectionRef = useRef(null); + const lassoPoints = useRef([]); + const onZoomChangeRef = useRef(onZoomChange); + const imageRef = useRef(null); + const baseScaleRef = useRef(1); + const isDrawingRef = useRef(false); + + // Keep ref updated + useEffect(() => { + onZoomChangeRef.current = onZoomChange; + }, [onZoomChange]); + + // Expose methods to parent + useImperativeHandle(ref, () => ({ + getCanvas: () => fabricCanvasRef.current, + clearSelection: () => clearSelection(), + })); + + // Update selection ref when state changes + useEffect(() => { + currentSelectionRef.current = currentSelection; + }, [currentSelection]); + + // Initialize canvas + useEffect(() => { + if (!canvasRef.current) return; + + const canvas = new fabric.Canvas(canvasRef.current, { + selection: false, + backgroundColor: 'transparent', + preserveObjectStacking: true, + }); + fabricCanvasRef.current = canvas; + + const handleResize = () => { + const container = canvasRef.current?.parentElement; + if (container) { + const width = container.clientWidth; + const height = container.clientHeight; + canvas.setWidth(width); + canvas.setHeight(height); + + // Re-center image if it exists + if (imageRef.current) { + centerImage(canvas, imageRef.current, zoom / 100); + } + canvas.renderAll(); + } + }; + + // Initial resize - use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + handleResize(); + }); + window.addEventListener('resize', handleResize); + + // Mouse wheel zoom + const handleWheel = (opt) => { + const e = opt.e; + e.preventDefault(); + e.stopPropagation(); + + const delta = e.deltaY; + let newZoom = canvas.getZoom(); + newZoom *= 0.999 ** delta; + + // Clamp zoom between 0.1x and 10x + if (newZoom > 10) newZoom = 10; + if (newZoom < 0.1) newZoom = 0.1; + + // Zoom to point under cursor + const pointer = canvas.getPointer(e, true); + canvas.zoomToPoint({ x: pointer.x, y: pointer.y }, newZoom); + + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + canvas.on('mouse:wheel', handleWheel); + + return () => { + window.removeEventListener('resize', handleResize); + canvas.off('mouse:wheel', handleWheel); + canvas.dispose(); + }; + }, []); // Empty dependency array - only run once on mount + + // Center and scale image + const centerImage = (canvas, img, zoomFactor) => { + if (!img) return; + + const padding = 40; + const availableWidth = canvas.width - padding; + const availableHeight = canvas.height - padding; + + // Calculate base scale to fit + const fitScale = Math.min( + availableWidth / img.width, + availableHeight / img.height + ); + + baseScaleRef.current = fitScale; + const scale = fitScale * zoomFactor; + + img.scale(scale); + img.set({ + left: (canvas.width - img.width * scale) / 2, + top: (canvas.height - img.height * scale) / 2, + }); + }; + + // Apply zoom changes + useEffect(() => { + const canvas = fabricCanvasRef.current; + if (!canvas || !imageRef.current) return; + + centerImage(canvas, imageRef.current, zoom / 100); + canvas.renderAll(); + }, [zoom]); + + // Load image when URL changes + useEffect(() => { + if (!fabricCanvasRef.current || !imageUrl) return; + + const canvas = fabricCanvasRef.current; + + // Ensure canvas has dimensions before loading image + if (canvas.width === 0 || canvas.height === 0) { + const container = canvasRef.current?.parentElement; + if (container) { + canvas.setWidth(container.clientWidth || 800); + canvas.setHeight(container.clientHeight || 600); + } + } + + // Add cache buster to force reload + const cacheBustedUrl = `${imageUrl}?t=${Date.now()}`; + + fabric.Image.fromURL(cacheBustedUrl, (img) => { + if (!img) { + console.error('Failed to load image from URL:', cacheBustedUrl); + return; + } + + canvas.clear(); + + // Scale image to fit canvas with padding + const padding = 40; + const availableWidth = (canvas.width || 800) - padding; + const availableHeight = (canvas.height || 600) - padding; + const scale = Math.min( + availableWidth / img.width, + availableHeight / img.height + ); + + img.scale(scale); + img.set({ + left: ((canvas.width || 800) - img.width * scale) / 2, + top: ((canvas.height || 600) - img.height * scale) / 2, + selectable: false, + evented: false, + hoverCursor: 'default', + }); + + imageRef.current = img; + canvas.add(img); + canvas.sendToBack(img); + + centerImage(canvas, img, zoom / 100); + canvas.renderAll(); + }, { crossOrigin: 'anonymous' }); + }, [imageUrl]); + + // Handle tool/mode changes + useEffect(() => { + if (!fabricCanvasRef.current) return; + + const canvas = fabricCanvasRef.current; + + // Remove all event handlers + canvas.off('mouse:down'); + canvas.off('mouse:move'); + canvas.off('mouse:up'); + canvas.off('object:modified'); + canvas.off('object:moving'); + canvas.off('object:scaling'); + + // Set up handlers based on selection mode or advanced tool mode + if (advancedToolMode === 'smart-select') { + setupSmartSelectMode(canvas); + } else if (advancedToolMode === 'color-select') { + setupColorSelectMode(canvas); + } else if (selectionMode === 'rectangle') { + setupRectangleMode(canvas); + } else if (selectionMode === 'ellipse') { + setupEllipseMode(canvas); + } else if (selectionMode === 'lasso') { + setupLassoMode(canvas); + } else if (selectionMode === 'move') { + setupMoveMode(canvas); + } else if (selectionMode === 'pan') { + setupPanMode(canvas); + } + }, [selectionMode, advancedToolMode, onAdvancedToolClick, isProcessing]); + + const setupMoveMode = (canvas) => { + // In move mode, allow selecting and moving selection objects + const sel = currentSelectionRef.current; + if (sel) { + sel.set({ selectable: true, evented: true }); + canvas.setActiveObject(sel); + } + + canvas.on('object:modified', (e) => { + if (e.target && e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupPanMode = (canvas) => { + let isPanning = false; + let lastPosX, lastPosY; + + canvas.on('mouse:down', (e) => { + isPanning = true; + lastPosX = e.e.clientX; + lastPosY = e.e.clientY; + canvas.setCursor('grabbing'); + }); + + canvas.on('mouse:move', (e) => { + if (!isPanning) return; + + const deltaX = e.e.clientX - lastPosX; + const deltaY = e.e.clientY - lastPosY; + + canvas.relativePan({ x: deltaX, y: deltaY }); + + lastPosX = e.e.clientX; + lastPosY = e.e.clientY; + }); + + canvas.on('mouse:up', () => { + isPanning = false; + canvas.setCursor('grab'); + }); + + canvas.setCursor('grab'); + }; + + const setupSmartSelectMode = (canvas) => { + canvas.on('mouse:down', (e) => { + if (isProcessing) return; + + const pointer = canvas.getPointer(e.e); + const img = imageRef.current; + + if (!img) return; + + // Convert to image coordinates + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const x = Math.round((pointer.x - imgLeft) / imgScale); + const y = Math.round((pointer.y - imgTop) / imgScale); + + // Check if click is within image bounds + if (x >= 0 && x < img.width && y >= 0 && y < img.height) { + onAdvancedToolClick?.(x, y, null); + } + }); + + canvas.setCursor('crosshair'); + }; + + const setupColorSelectMode = (canvas) => { + canvas.on('mouse:down', (e) => { + if (isProcessing) return; + + const pointer = canvas.getPointer(e.e); + const img = imageRef.current; + + if (!img) return; + + // Convert to image coordinates + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const x = Math.round((pointer.x - imgLeft) / imgScale); + const y = Math.round((pointer.y - imgTop) / imgScale); + + // Check if click is within image bounds + if (x >= 0 && x < img.width && y >= 0 && y < img.height) { + // Get pixel color from canvas + const ctx = canvas.getContext('2d'); + if (ctx) { + // Calculate actual canvas position accounting for viewport transform + const vpt = canvas.viewportTransform; + const canvasX = pointer.x * vpt[0] + vpt[4]; + const canvasY = pointer.y * vpt[3] + vpt[5]; + + const pixelData = ctx.getImageData(canvasX, canvasY, 1, 1).data; + const color = { + r: pixelData[0], + g: pixelData[1], + b: pixelData[2] + }; + onAdvancedToolClick?.(x, y, color); + } + } + }); + + canvas.setCursor('crosshair'); + }; + + const setupRectangleMode = (canvas) => { + let rect = null; + let isDown = false; + let startX, startY; + + canvas.on('mouse:down', (e) => { + // Check if clicking on existing selection + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + // Allow moving/transforming + return; + } + + // Clear previous selection + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDown = true; + isDrawingRef.current = true; + const pointer = canvas.getPointer(e.e); + startX = pointer.x; + startY = pointer.y; + + rect = new fabric.Rect({ + left: startX, + top: startY, + width: 0, + height: 0, + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(rect); + }); + + canvas.on('mouse:move', (e) => { + if (!isDown || !rect) return; + + const pointer = canvas.getPointer(e.e); + const width = pointer.x - startX; + const height = pointer.y - startY; + + rect.set({ + width: Math.abs(width), + height: Math.abs(height), + left: width < 0 ? pointer.x : startX, + top: height < 0 ? pointer.y : startY, + }); + + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDown && rect && rect.width > 5 && rect.height > 5) { + isDown = false; + isDrawingRef.current = false; + setCurrentSelection(rect); + canvas.setActiveObject(rect); + updateSelection(rect, 'rectangle'); + } else if (isDown && rect) { + // Selection too small, remove it + canvas.remove(rect); + isDown = false; + isDrawingRef.current = false; + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupEllipseMode = (canvas) => { + let ellipse = null; + let isDown = false; + let startX, startY; + + canvas.on('mouse:down', (e) => { + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + return; + } + + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDown = true; + const pointer = canvas.getPointer(e.e); + startX = pointer.x; + startY = pointer.y; + + ellipse = new fabric.Ellipse({ + left: startX, + top: startY, + rx: 0, + ry: 0, + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(ellipse); + }); + + canvas.on('mouse:move', (e) => { + if (!isDown || !ellipse) return; + + const pointer = canvas.getPointer(e.e); + const rx = Math.abs(pointer.x - startX) / 2; + const ry = Math.abs(pointer.y - startY) / 2; + + ellipse.set({ + rx: rx, + ry: ry, + left: Math.min(startX, pointer.x), + top: Math.min(startY, pointer.y), + }); + + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDown && ellipse && ellipse.rx > 5 && ellipse.ry > 5) { + isDown = false; + setCurrentSelection(ellipse); + canvas.setActiveObject(ellipse); + updateSelection(ellipse, 'ellipse'); + } else if (isDown && ellipse) { + canvas.remove(ellipse); + isDown = false; + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const setupLassoMode = (canvas) => { + let points = []; + let drawingLine = null; + let polygon = null; + + canvas.on('mouse:down', (e) => { + const sel = currentSelectionRef.current; + if (e.target && e.target === sel) { + return; + } + + if (sel) { + canvas.remove(sel); + setCurrentSelection(null); + } + + isDrawingRef.current = true; + const pointer = canvas.getPointer(e.e); + points = [{ x: pointer.x, y: pointer.y }]; + + drawingLine = new fabric.Polyline(points, { + fill: 'transparent', + stroke: '#0088ff', + strokeWidth: 2, + selectable: false, + evented: false, + }); + + canvas.add(drawingLine); + }); + + canvas.on('mouse:move', (e) => { + if (!isDrawingRef.current) return; + + const pointer = canvas.getPointer(e.e); + points.push({ x: pointer.x, y: pointer.y }); + + canvas.remove(drawingLine); + drawingLine = new fabric.Polyline([...points], { + fill: 'transparent', + stroke: '#0088ff', + strokeWidth: 2, + selectable: false, + evented: false, + }); + canvas.add(drawingLine); + canvas.renderAll(); + }); + + canvas.on('mouse:up', () => { + if (isDrawingRef.current && points.length > 5) { + isDrawingRef.current = false; + lassoPoints.current = [...points]; + + canvas.remove(drawingLine); + + polygon = new fabric.Polygon(points, { + fill: 'rgba(0, 136, 255, 0.2)', + stroke: '#0088ff', + strokeWidth: 2, + strokeDashArray: [5, 5], + selectable: true, + hasControls: true, + hasBorders: true, + cornerColor: '#0088ff', + cornerSize: 8, + transparentCorners: false, + borderColor: '#0088ff', + }); + + canvas.add(polygon); + canvas.setActiveObject(polygon); + setCurrentSelection(polygon); + updateSelection(polygon, 'lasso'); + } else if (isDrawingRef.current) { + isDrawingRef.current = false; + canvas.remove(drawingLine); + } + }); + + canvas.on('object:modified', (e) => { + if (e.target === currentSelectionRef.current) { + updateTransformedSelection(e.target); + } + }); + }; + + const updateSelection = (selection, type) => { + if (!selection || !imageRef.current) return; + + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + let bbox, selectionData = null; + + if (type === 'rectangle') { + bbox = { + x: Math.round((selection.left - imgLeft) / imgScale), + y: Math.round((selection.top - imgTop) / imgScale), + width: Math.round(selection.width / imgScale), + height: Math.round(selection.height / imgScale), + }; + } else if (type === 'ellipse') { + bbox = { + x: Math.round((selection.left - imgLeft) / imgScale), + y: Math.round((selection.top - imgTop) / imgScale), + width: Math.round((selection.rx * 2) / imgScale), + height: Math.round((selection.ry * 2) / imgScale), + }; + } else if (type === 'lasso') { + const bounds = selection.getBoundingRect(); + bbox = { + x: Math.round((bounds.left - imgLeft) / imgScale), + y: Math.round((bounds.top - imgTop) / imgScale), + width: Math.round(bounds.width / imgScale), + height: Math.round(bounds.height / imgScale), + }; + + const relativePoints = lassoPoints.current.map(p => [ + Math.round((p.x - imgLeft) / imgScale) - bbox.x, + Math.round((p.y - imgTop) / imgScale) - bbox.y, + ]); + + selectionData = { points: relativePoints }; + } + + onSelectionChange?.({ + type, + bbox, + selectionData, + }); + }; + + const updateTransformedSelection = (selection) => { + if (!selection || !imageRef.current) return; + + const img = imageRef.current; + const imgScale = img.scaleX; + const imgLeft = img.left; + const imgTop = img.top; + + const bounds = selection.getBoundingRect(true); + + const bbox = { + x: Math.round((bounds.left - imgLeft) / imgScale), + y: Math.round((bounds.top - imgTop) / imgScale), + width: Math.round(bounds.width / imgScale), + height: Math.round(bounds.height / imgScale), + }; + + let selectionData = null; + const type = selection.type === 'polygon' ? 'lasso' : (selection.type === 'ellipse' ? 'ellipse' : 'rectangle'); + + if (type === 'lasso' && lassoPoints.current.length > 0) { + const matrix = selection.calcTransformMatrix(); + const transformedPoints = lassoPoints.current.map(p => { + const transformed = fabric.util.transformPoint( + new fabric.Point(p.x, p.y), + matrix + ); + return [ + Math.round((transformed.x - imgLeft) / imgScale) - bbox.x, + Math.round((transformed.y - imgTop) / imgScale) - bbox.y, + ]; + }); + selectionData = { points: transformedPoints }; + } + + onSelectionChange?.({ + type, + bbox, + selectionData, + }); + }; + + const clearSelection = () => { + const canvas = fabricCanvasRef.current; + const sel = currentSelectionRef.current; + if (sel && canvas) { + canvas.remove(sel); + setCurrentSelection(null); + onSelectionChange?.(null); + } + }; + + const handleZoomIn = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + let newZoom = canvas.getZoom() * 1.2; + if (newZoom > 10) newZoom = 10; + canvas.setZoom(newZoom); + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + const handleZoomOut = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + let newZoom = canvas.getZoom() / 1.2; + if (newZoom < 0.1) newZoom = 0.1; + canvas.setZoom(newZoom); + setCurrentZoom(newZoom); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(newZoom); + } + }; + + const handleZoomReset = () => { + if (!fabricCanvasRef.current) return; + const canvas = fabricCanvasRef.current; + canvas.setZoom(1); + canvas.setViewportTransform([1, 0, 0, 1, 0, 0]); + setCurrentZoom(1); + if (onZoomChangeRef.current) { + onZoomChangeRef.current(1); + } + }; + + return ( +
+ +
+
+ + {Math.round(currentZoom * 100)}% + + +
+ {currentSelection && ( + + )} +
+ {(advancedToolMode === 'smart-select' || advancedToolMode === 'color-select') && ( +
+ {advancedToolMode === 'smart-select' ? 'Click on an object to select it' : 'Click on a color to select similar pixels'} +
+ )} +
+ ); +}); + +ImageCanvas.displayName = 'ImageCanvas'; + +export default ImageCanvas; diff --git a/paintplus/frontend/src/css/component.css b/paintplus/frontend/src/css/component.css new file mode 100644 index 0000000..6791eb1 --- /dev/null +++ b/paintplus/frontend/src/css/component.css @@ -0,0 +1,732 @@ +/*****************\ +| UI Button Group | +\*****************/ + +.ui_button_group { + display: flex; + flex-direction: row; + flex-wrap: wrap; +} +.ui_button_group.no_wrap { + flex-wrap: nowrap; +} +.ui_button_group.stacked { + margin: .75rem 0; +} +.ui_button_group.stacked:first-child { + margin-top: 0; +} +.ui_button_group.stacked:last-child { + margin-bottom: 0; +} +.ui_button_group > button, +.ui_button_group > input[type="button"] { + border-radius: 0; +} +.ui_button_group > button:focus, +.ui_button_group > input[type="button"]:focus { + z-index: 1; +} +.ui_button_group > button + button, +.ui_button_group > button + input[type="button"], +.ui_button_group > input[type="button"] + button, +.ui_button_group > input[type="button"] + input[type="button"] { + margin-left: -1px; +} +.ui_button_group > button:first-child, +.ui_button_group > input[type="button"]:first-child { + border-radius: var(--button-border-radius) 0 0 var(--button-border-radius); +} +.ui_button_group > button:last-child, +.ui_button_group > input[type="button"]:last-child { + border-radius: 0 var(--button-border-radius) var(--button-border-radius) 0; +} + +/****************\ +| UI Color Input | +\****************/ + +.ui_color_input { + display: inline-block; + padding: 0; + margin: 0; + position: relative; + overflow: hidden; + vertical-align: middle; +} + +.ui_color_input input[type="color"] { + display: block; + cursor: pointer; + padding: 0; + border: .2rem solid var(--input-background-color); + width: 3rem; +} + +.ui_color_input .alpha_overlay { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64paAAABhWlDQ1BJQ0MgcHJvZmlsZQAAKJF9kT1Iw1AUhU9bRdGKiBlERDJUJwuiIo5ahSJUCLVCqw4mL/2DJg1Jiouj4Fpw8Gex6uDirKuDqyAI/oA4OTopukiJ9yWFFjFeeLyP8+45vHcfEKyVmGa1jQOabpvJeExMZ1bFjld0YxgBCOiTmWXMSVICvvV1T51Ud1Ge5d/3Z/WoWYsBAZF4lhmmTbxBPL1pG5z3iQVWkFXic+Ixky5I/Mh1xeM3znmXgzxTMFPJeWKBWMy3sNLCrGBqxFPEEVXTKT+Y9ljlvMVZK1VY4578heGsvrLMdVpDiGMRS5AgQkEFRZRgI0q7ToqFJJ3HfPyDrl8il0KuIhg5FlCGBtn1g//B79lauckJLykcA9pfHOdjBOjYBepVx/k+dpz6CRB6Bq70pr9cA2Y+Sa82tcgR0LsNXFw3NWUPuNwBBp4M2ZRdKUQrmMsB72f0TRmg/xboWvPm1jjH6QOQolklboCDQ2A0T9nrPu/ubJ3bvz2N+f0AL+pyjMZuudYAAAAJcEhZcwAALiMAAC4jAXilP3YAAAAHdElNRQfkCx4BHwaj7CMVAAAAGXRFWHRDb21tZW50AENyZWF0ZWQgd2l0aCBHSU1QV4EOFwAAAC5JREFUOMtjfPfuHQNuICgoiEeWiYECMKp5ZGhm/P//Px7p9+/fjwbYqGZKNAMAANAI7r7rfkQAAAAASUVORK5CYII='); + background-size: 100% 100%; + position: absolute; + top: 3px; + left: 3px; + right: 3px; + bottom: 3px; + pointer-events: none; +} + +/**************************\ +| UI Color Picker Gradient | +\**************************/ + +.ui_color_picker_gradient { + padding: 0 0 80% 0; + position: relative; + width: 100%; +} + +.ui_color_picker_gradient .primary_pick { + position: absolute; + left: 86%; + right: 0; + top: 0; + bottom: 0; + background: white; +} + +.ui_color_picker_gradient .secondary_pick { + position: absolute; + left: 0; + right: 17%; + top: 0; + bottom: 0; + border: 1px solid var(--border-color); + background: green; +} + +.ui_color_picker_gradient .secondary_pick:focus { + outline: 0; + border: 1px solid var(--input-border-color-active); +} + +.ui_color_picker_gradient .secondary_pick .saturation_gradient { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: linear-gradient(to right, #fff, rgba(204, 154, 129, 0)); +} + +.ui_color_picker_gradient .secondary_pick .value_gradient { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + background: linear-gradient(to top, #000, rgba(204, 154, 129, 0)); +} + +.ui_color_picker_gradient .secondary_pick .handle { + position: absolute; + left: 0; + right: auto; + top: 0; + bottom: auto; + pointer-events: none; +} + +.ui_color_picker_gradient .secondary_pick .handle:before { + content: ''; + display: block; + position: absolute; + left: -.6rem; + top: -.6rem; + height: .3rem; + width: .3rem; + border: .4rem solid #999; + border-radius: 1000px; +} + +.ui_color_picker_gradient .secondary_pick .handle:after { + content: ''; + display: block; + position: absolute; + left: -.5rem; + top: -.5rem; + height: .5rem; + width: .5rem; + border: .2rem solid white; + border-radius: 1000px; +} + +.ui_color_picker_gradient .primary_pick .ui_range { + border-color: rgba(1, 1, 1, 0.1); +} +.ui_color_picker_gradient .primary_pick .ui_range:focus { + border-color: var(--input-border-color-active); +} + +/*****************\ +| UI Color Sample | +\*****************/ + +.ui_color_sample { + border: 1px solid #999; + box-shadow: 0 0 0 1px #555 inset; + display: block; + height: 28px; + width: 28px; +} + +/***************\ +| UI Flex Group | +\***************/ + +.ui_flex_group { + display: flex; + flex-direction: row; +} +.ui_flex_group.stacked { + margin: .75rem 0; +} +.ui_flex_group.stacked:first-child { + margin-top: 0; +} +.ui_flex_group.stacked:last-child { + margin-bottom: 0; +} +.ui_flex_group.column { + flex-direction: column; +} +.ui_flex_group.justify_content_center { + justify-content: center; +} +.ui_flex_group.justify_content_start { + justify-content: flex-start; +} +.ui_flex_group.justify_content_end { + justify-content: flex-end; +} +.ui_flex_group.justify_content_space_around { + justify-content: space-around; +} +.ui_flex_group.justify_content_space_between { + justify-content: space-between; +} +.ui_flex_group.align_items_baseline { + align-items: baseline; +} +.ui_flex_group.align_items_center { + align-items: center; +} +.ui_flex_group.align_items_start { + align-items: flex-start; +} +.ui_flex_group.align_items_end { + align-items: flex-end; +} +.ui_flex_group.align_items_stretch { + align-items: stretch; +} + +/****************\ +| UI Icon Button | +\****************/ + +.ui_icon_button { + height: 2.8rem; + line-height: 2.8rem; +} + +.ui_icon_button.input_height { + height: 2.4rem; + line-height: 2.4rem; +} + +.ui_icon_button > svg { + display: block; + font-size: 1.6rem; +} +.ui_icon_button > img { + display: block; + margin: 0 auto; +} +button img{ + filter: var(--menu-icons-filter); +} + +/****************\ +| UI Input Group | +\****************/ + +.ui_input_group { + display: flex; + flex-direction: row; + min-height: 2.4rem; + width: 100%; +} +.ui_input_group.stacked { + margin: .75rem 0; +} +.ui_input_group.stacked:first-child { + margin-top: 0; +} +.ui_input_group.stacked:last-child { + margin-bottom: 0; +} +.ui_input_group > input, +.ui_input_group > .ui_number_input, +.ui_input_group > .ui_range, +.ui_input_group > .ui_color_sample { + border-radius: 0; + height: auto; + min-width: 0; +} +.ui_input_group > .ui_color_sample { + border: none; + width: 100%; +} +.ui_input_group > :first-child { + border-radius: var(--input-border-radius) 0 0 var(--input-border-radius); +} +.ui_input_group > :last-child { + border-radius: 0 var(--input-border-radius) var(--input-border-radius) 0; +} +.ui_input_group > label { + display: flex; + align-items: center; + border: 1px solid var(--input-group-border-color); + border-right: 0; + margin: 0; + padding: 0 .75rem; +} +.ui_input_group > .ui_range + input, +.ui_input_group > .ui_range + .ui_number_input { + margin-left: -1px; +} + +.ui_input_grid { + border-radius: var(--input-border-radius); + box-shadow: 0 1px 0 0 rgba(1, 1, 1, 0.1); +} +.ui_input_grid.stacked { + margin: .75rem 0; +} +.ui_input_grid.stacked:first-child { + margin-top: 0; +} +.ui_input_grid.stacked:last-child { + margin-bottom: 0; +} +:not(.ui_input_grid) > .ui_input_group { + border-radius: var(--input-border-radius); + box-shadow: 0 1px 0 0 rgba(1, 1, 1, 0.1); +} +.ui_input_grid > .ui_input_group { + margin: -1px 0; +} +.ui_input_grid > .ui_input_group > :first-child, +.ui_input_grid > .ui_input_group > :last-child { + border-radius: 0; +} +.ui_input_grid > .ui_input_group:first-child { + margin-top: 0; +} +.ui_input_grid > .ui_input_group:first-child > :first-child { + border-radius: var(--input-border-radius) 0 0 0; +} +.ui_input_grid > .ui_input_group:first-child > :last-child { + border-radius: 0 var(--input-border-radius) 0 0; +} +.ui_input_grid > .ui_input_group:last-child { + margin-bottom: 0; +} +.ui_input_grid > .ui_input_group:last-child > :first-child { + border-radius: 0 0 0 var(--input-border-radius); +} +.ui_input_grid > .ui_input_group:last-child > :last-child { + border-radius: 0 0 var(--input-border-radius) 0; +} + +/*****************\ +| UI Number Input | +\*****************/ + +.ui_number_input { + border: 1px solid var(--input-border-color); + border-radius: var(--input-border-radius); + display: inline-block; + padding: 0; + margin: 0; + position: relative; + overflow: hidden; + vertical-align: middle; +} + +.ui_number_input > input[type="number"]::-webkit-outer-spin-button, +.ui_number_input > input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.ui_number_input > input[type="number"] { + border: none; + border-radius: 0; + -moz-appearance: textfield; + appearance: textfield; + padding-right: 2.5rem; + padding-right: calc(var(--number-input-arrow-width) + .5rem); + width: 100%; +} + +.ui_number_input > .increase_number, +.ui_number_input > .decrease_number { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + width: 2rem; + width: var(--number-input-arrow-width); + border-radius: 0; + border: 1px solid var(--input-border-color); + border-right: none; + padding: 0; + margin: 0; +} +.ui_number_input > ::-moz-focus-inner { + border: 0; +} +.ui_number_input > .increase_number:focus, +.ui_number_input > .decrease_number:focus { + outline: 0; +} +.ui_number_input > .increase_number { + right: 0; + top: 0; + bottom: 50%; + border-top: none; +} +.ui_number_input > .increase_number::after { + content: ''; + display: block; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-bottom: 3px solid var(--input-text-color); +} +.ui_number_input > .decrease_number { + right: 0; + top: calc(50% - 1px); + bottom: 0; + border-bottom: none; +} +.ui_number_input > .decrease_number::after { + content: ''; + display: block; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 3px solid var(--input-text-color); +} + +/**********\ +| UI Range | +\**********/ + +:root { + --range-handle-width: 18px; +} + +.ui_range { + display: flex; + flex-direction: row; + background: var(--input-background-color); + border: 1px solid var(--input-border-color); + border-radius: 1000px; + height: 1.8rem; + overflow: visible; + outline: 0; + padding: 0 calc(var(--range-handle-width) / 2); + position: relative; + width: 100%; +} +.ui_range:focus { + border-color: var(--input-border-color-active); + z-index: 1; +} + +.ui_range.active { + cursor: col-resize; +} + +.ui_range .padded_track { + position: absolute; + left: calc(var(--range-handle-width) / 2); + right: calc(var(--range-handle-width) / 2); + top: 0; + bottom: 0; +} + +.ui_range .bar { + overflow: visible; + position: relative; + width: 0%; +} + +.ui_range .handle { + background: var(--input-text-color); + border: 1px solid var(--border-color); + border-radius: 1000px; + box-sizing: border-box; + cursor: col-resize; + display: block; + height: 1.8rem; + width: var(--range-handle-width); + position: absolute; + top: 50%; + right: 0; + transform: translate(50%, -50%); +} + +.ui_range.color_picker .handle { + background: none; + border: none; + border-radius: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + height: auto; + top: 0; + bottom: 0; + transform: translateX(50%); +} + +.ui_range.color_picker .handle::before { + content: ''; + display: block; + width: 0; + height: 0; + border-left: .5rem solid transparent; + border-right: .5rem solid transparent; + border-top: .7rem solid white; +} +.ui_range.color_picker .handle::after { + content: ''; + display: block; + width: 0; + height: 0; + border-left: .5rem solid transparent; + border-right: .5rem solid transparent; + border-bottom: .7rem solid black; +} +.ui_range.color_picker .handle:hover::before { + border-top-color: #eaeaea; +} +.ui_range.color_picker .handle:hover::after { + border-bottom-color: #222; +} + +.ui_range.vertical { + flex-direction: column; + justify-content: flex-end; + height: 100%; + width: 1.8rem; + padding: calc(var(--range-handle-width) / 2) 0; +} + +.ui_range.vertical.active { + cursor: row-resize; +} + +.ui_range.vertical .padded_track { + left: 0; + right: 0; + top: calc(var(--range-handle-width) / 2); + bottom: calc(var(--range-handle-width) / 2); +} + +.ui_range.vertical .bar { + width: 100%; + height: 0%; +} + +.ui_range.vertical .handle { + transform: translate(50%, -50%); + top: 0; + right: 50%; + cursor: row-resize; +} + +.ui_range.vertical.color_picker_thin { + padding: 1px 0; + border-radius: 0; + width: 100%; +} + +.ui_range.vertical.color_picker_thin .padded_track { + top: 0; + bottom: 0; +} + +.ui_range.vertical.color_picker_thin .handle { + border-radius: 0; + width: 100%; + height: .5rem; +} + +/*************\ +| UI Swatches | +\*************/ + +.ui_swatches { + display: flex; + justify-content: center; +} + +.ui_swatches .swatch_group { + display: flex; + flex-direction: row; + flex-wrap: wrap; + margin: auto; + border-radius: var(--input-border-radius); + border: 1px solid var(--border-color); + border-right: transparent; + box-shadow: 0 1px 0 0 rgba(1, 1, 1, 0.1); + overflow: hidden; + max-height: calc(2.3rem); +} +.ui_swatches .swatch_group:focus { + outline: 0; + box-shadow: 0 0 0 1px var(--input-border-color-active); +} + +.ui_swatches .swatch_group.rows_2 { + max-height: calc(4.6rem - 1px); +} +.ui_swatches .swatch_group.rows_3 { + max-height: calc(6.9rem - 2px); +} +.ui_swatches .swatch_group.cols_1 .swatch { + width: 100%; +} +.ui_swatches .swatch_group.cols_2 .swatch { + width: 50%; +} +.ui_swatches .swatch_group.cols_3 .swatch { + width: 33.33%; +} +.ui_swatches .swatch_group.cols_4 .swatch { + width: 25%; +} +.ui_swatches .swatch_group.cols_5 .swatch { + width: 20%; +} +.ui_swatches .swatch_group.cols_6 .swatch { + width: 16.66%; +} +.ui_swatches .swatch_group.cols_7 .swatch { + width: 14.29%; +} +.ui_swatches .swatch_group.cols_8 .swatch { + width: 12.5%; +} + +.ui_swatches .swatch { + background: white; + display: inline-block; + position: relative; + border: 1px solid var(--border-color); + border-radius: 0; + box-shadow: 0 0 0 1px white inset; + margin: -1px 0 0 -1px; + padding: 0; + height: 2.3rem; + min-width: 2.3rem; + flex-grow: 1; +} + +.ui_swatches .swatch:hover, +.ui_swatches .swatch:focus { + background: white; + box-shadow: 0 0 0 2px white inset, 0 0 0 3px var(--border-color) inset; +} +.ui_swatches .swatch:hover:after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to bottom right, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.2) 50%, rgba(0, 0, 0, 0.1) 51%, rgba(0, 0, 0, 0.1) 100%); +} + +.ui_swatches .swatch.active { + box-shadow: 0 0 0 3px var(--button-text-color-active) inset, 0 0 0 4px var(--border-color) inset; +} + + +/******************\ +| UI Toggle Button | +\******************/ + +.ui_toggle_button { + padding-left: 2.6rem !important; + position: relative; +} +.ui_toggle_button:before { + background-color: var(--button-toggle-background-color); + background-image: url('data:image/svg+xml;utf8,'); + background-position: center; + background-repeat: no-repeat; + border-radius: var(--button-border-radius) 0 0 var(--button-border-radius); + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1.8rem; + content: ''; +} +.ui_toggle_button[aria-pressed="true"]:before { + background-color: var(--button-text-color-active); + background-image: url('data:image/svg+xml;utf8,'); +} + +/* media */ + +.media-paging{ + width: 100%; + margin: 10px 0; + text-align: center; +} +.media-paging button{ + background-color: var(--button-background-color); + color: var(--text-color); +} +.media-paging button.selected{ + background-color: var(--background-color-active); + color: var(--text-color-active); +} + +/* global search */ +#global_search_results{ + padding-top: 10px; + font-size: 14px; +} +#global_search_results .search-result { + padding: 3px 5px; +} +#global_search_results .search-result.active{ + background-color: var(--background-color-active); + color: var(--text-color-active); + border-radius: 2px; +} +#global_search_results b{ + color: var(--text-color-red); +} + +.popup.shortcuts table{ + line-height: 1; +} \ No newline at end of file diff --git a/paintplus/frontend/src/css/layout.css b/paintplus/frontend/src/css/layout.css new file mode 100644 index 0000000..ea887ab --- /dev/null +++ b/paintplus/frontend/src/css/layout.css @@ -0,0 +1,971 @@ +.wrapper{ + display: -ms-grid; + display: grid; + margin: 0; + position: fixed; /* dont change it, vh does not work on mobiles with bottom footer */ + top: 30px; + right: 0; + left: 0; + bottom: 5px; + height: auto; + overflow: hidden; + + -ms-grid-rows: auto 1fr; + grid-template-rows: auto 1fr; + -ms-grid-columns: auto 1fr auto; + grid-template-columns: auto 1fr auto; + + grid-template-areas: + "submenu submenu submenu" + "sidebar_left main sidebar_right"; +} +.trn{} +.toggle{ + cursor: pointer; +} +.hidden{ + display:none; +} +.center{ + text-align: center; +} +.pointer{ + cursor: pointer; +} +.clear{ + clear:both; +} +.displayBlock{ + display: block; +} +.bold{ + font-weight: bold; +} +.left{ + float: left; +} +.right{ + float: right; +} +.grey{ + color:grey; +} +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version */ +} +.block{ + position: relative; + background-color: rgba(255, 255, 255, 0.2); + background-color: var(--block-background-color); + border: 1px solid rgba(0, 0, 0, 0.5); + border: 1px solid var(--border-color); + margin-bottom: 10px; + user-select: none; + border-radius: 4px; +} +.sidebar_right .block{ + background-color: #68727b; + background-color: var(--block-background-color); + border-bottom: none; + box-shadow: 0 -2px 0 0 var(--header-background-color) inset; +} +.block:last-child{ + margin-bottom: 0; +} +.block h2{ + position: relative; + padding: 2px 5px 2px 6px; + margin: 0; + font-size: 110%; + background-color: rgba(255, 255, 255, 0.3); + background-color: var(--header-background-color); + border-bottom: #555; + border-radius: 4px 4px 0 0; +} +.block.toggled h2, .block h2.toggled:after{ + border: none; +} +.block h2.toggle:before{ + /* icon */ + position:absolute; + content:''; + width: 0; + height: 0; + right: 10px; + top: 10px; + border-style: solid; + border-width: 0 4px 5px 4px; + border-color: transparent transparent var(--text-color-muted) transparent; +} +.block h2.toggled:before{ + /* icon */ + border-width: 5px 4px 0 4px; + border-color: var(--text-color-muted) transparent transparent transparent; +} +.block .content{ + padding: 7.5px 5px; +} +.block_section { + margin: .75rem 0; +} +.block_section:first-child { + margin-top: 0; +} +.block_section:last-child { + margin-bottom: 0; +} +.error{ + padding:20px; + margin:10px; + border:1px solid #ff0000; + background-color:#ffffff; + width:500px; + font-weight:bold; +} + +/* color chooser */ +body .sp-replacer{ + width: 100%; + height: 40px; +} +body .sp-preview{ + width: calc(100% - 20px); + height: 100%; +} + +/* ========== header ======================================================== */ + +.logo{ + position: relative; + display: inline-block; + height: 30px; + width: 110px; + padding: 5px 5px 5px 36px; + margin: 5px; + font-size: 14px; + text-decoration: none; + font-weight: bold; + color: #ffffff; + color: var(--text-color); +} +.logo:after{ + position:absolute; + content:""; + left: 0; + top: 0; + width: 31px; + height: 30px; + background: url('images/logo.svg') no-repeat center center; + background-size: auto 28px; + filter: var(--mobile-menu-toggle-filter); +} +.logo:hover:after{ + left: 2px; +} +.about-logo{ + margin-left:22%; +} +.about-name{ + font-size:15px; + font-weight:bold; +} +.undo_button { + display: none; + width: 50px; + height: 50px; + top: 0; + border: 0; + outline: none; + cursor: pointer; + filter: var(--mobile-menu-toggle-filter); + background: url(images/icons/undo.svg) no-repeat center center; + background-size: auto 25px; + margin-left: 10px; +} +.undo_button:hover { + background-color: transparent; +} +@media screen and (max-width: 700px){ + .undo_button { + display: block; + } +} + +/* ========== sub-header ==================================================== */ + +.submenu{ + -ms-grid-row: 1; + -ms-grid-column: 1; + -ms-grid-column-span: 3; + grid-area: submenu; + display: flex; + flex-direction: row; + align-items: center; + background-color: rgba(255, 255, 255, 0.2); + background-color: var(--section-background-color); + overflow: hidden; + margin-bottom: 5px; +} +.attributes{ + display: flex; + flex-wrap: nowrap; + background-color: var(--area-background-color); + width: calc(100% - 125px); + margin-top: 5px; + margin-bottom: 5px !important; + padding: 3px 10px 3px 10px; + border: 0; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + min-height: 30px; +} +.attributes .item{ + display: inline-flex; + align-items: center; + margin-right: 20px; +} +.attributes .item > label { + margin: 0 .5rem 0 0; +} +.attributes input[type="number"]{ + width: 60px; + margin-right: 5px; +} +.attributes input[type="color"] { + cursor: pointer; + padding: 0; + border: .2rem solid var(--input-background-color); + width: 3rem; +} +.attributes .item > button:not(.ui_icon_button){ + display: inline-block; + padding: 3px 10px; +} + +/* ========== left sidebar ================================================== */ + +.sidebar_left{ + -ms-grid-row: 2; + -ms-grid-column: 1; + grid-area: sidebar_left; + display: flex; + flex-direction: row; + flex-wrap: wrap; + background-color: var(--section-background-color); + padding: 0 5px 5px 0; + margin-right: 5px; + overflow: hidden; + align-self: start; + width: 40px; + overflow-y: auto; + max-height: 100%; +} +.sidebar_left .item{ + position: relative; + display:block; + background-color: var(--area-background-color); + height: 25px; + width: 30px; + margin: 5px 0 0 5px; + overflow: hidden; + cursor: pointer; +} +.sidebar_left .item:after{ + position: absolute; + content: ''; + left:0; + top:0; + bottom:0; + right:0; + filter: var(--menu-icons-filter); + background-position: center center; + background-repeat: no-repeat; + background-size: 20px 20px; +} +.sidebar_left .item:hover{ + background-color: var(--background-color-hover); +} +.sidebar_left .item.active{ + background-color: var(--background-color-active); + color: var(--text-color-active); +} +.sidebar_left .item.active:after{ + filter: var(--menu-icons-filter-active); +} + +/* +IMPORTANT: any new icon should also must be added on /service-worker.js + its version should be updated - FEATURE DISABLED + */ +.sidebar_left .select:after{ background-image: url('images/icons/select.svg'); } +.sidebar_left .selection:after{ background-image: url('images/icons/selection.svg'); } +.sidebar_left .brush:after{ background-image: url('images/icons/brush.svg'); } +.sidebar_left .pencil:after{ background-image: url('images/icons/pencil.svg'); } +.sidebar_left .pick_color:after{ background-image: url('images/icons/pick_color.svg'); } +.sidebar_left .erase:after{ background-image: url('images/icons/erase.svg'); } +.sidebar_left .magic_erase:after{ background-image: url('images/icons/magic_erase.svg'); } +.sidebar_left .fill:after{ background-image: url('images/icons/fill.svg'); } +.sidebar_left .media:after{ background-image: url('images/icons/media.svg'); } +.sidebar_left .shape:after{ background-image: url('images/icons/shape.svg'); } +.sidebar_left .text:after{ background-image: url('images/icons/text.svg'); background-size: 16px auto; } +.sidebar_left .gradient:after{ background-image: url('images/icons/gradient.png'); background-size: 18px 12px; filter: none; } +.sidebar_left .clone:after{ background-image: url('images/icons/clone.svg'); } +.sidebar_left .crop:after{ background-image: url('images/icons/crop.svg'); } +.sidebar_left .blur:after{ background-image: url('images/icons/blur.svg'); } +.sidebar_left .sharpen:after{ background-image: url('images/icons/sharpen.svg'); } +.sidebar_left .desaturate:after{ background-image: url('images/icons/desaturate.svg'); } +.sidebar_left .bulge_pinch:after{ background-image: url('images/icons/bulge_pinch.svg'); } +.sidebar_left .animation:after{ background-image: url('images/icons/animation.svg'); } +.sidebar_left .smart_select:after{ background-image: url('images/icons/smart_select.svg'); } +.sidebar_left .brush_select:after{ background-image: url('images/icons/brush_select.svg'); } +.sidebar_left .ai_inpaint:after{ background-image: url('images/icons/ai_inpaint.svg'); } +.sidebar_left .ai_edit:after{ background-image: url('images/icons/ai_edit.svg'); } +.sidebar_left .magic_wand:after{ background-image: url('images/icons/magic_wand.svg'); } +.sidebar_left .lasso:after{ background-image: url('images/icons/lasso.svg'); } +.sidebar_left .ellipse_select:after{ background-image: url('images/icons/ellipse_select.svg'); } + +@media screen and (max-width:550px){ + #sidebar_left{ + left: -110px; + } +} + +/* ========== right sidebar ================================================= */ + +.sidebar_right{ + -ms-grid-row: 2; + -ms-grid-column: 3; + grid-area: sidebar_right; + z-index: 2; + display: flex; + flex-direction: column; + transition: 0.2s; + overflow-x: hidden; + overflow-y: scroll; + margin: 0 5px; + width: 200px; +} +.sidebar_right.active{ + right: 0 !important; +} +.sidebar_right .block.layers{ + flex: 1; +} +.sidebar_right .block.layers .content{ + padding-bottom: 25px; +} + +/* preview */ +.canvas_preview_wrapper{ + position:relative; + height:100px; + margin: 5px 5px 10px 5px; +} +.canvas_preview_details{ + padding: 0 5px; +} +.canvas_preview_details button{ + margin: 0; +} +.preview canvas{ + cursor: pointer; +} +.details input{ + padding: 5px 10px; +} + +/* color */ +.color_area{ + border: 1px solid #444; + width: calc(100% - 10px); + height: 40px; + cursor: pointer; + margin: 5px; +} + +/* layers */ +.layers_list{ + margin-top: 10px; +} +.layers_arrow{ + display:inline-block; + float:right; + margin-left:5px; + padding:1px 8px; + border:1px solid #444; + border-color: var(--border-color); + text-decoration:none; + color:var(--text-color); + font-size:12px; +} +.layer_add{ + display:inline-block; + padding:1px 8px; + margin-right: 10px; + background-color: #419147; + background-color: var(--background-color-active); + border:1px solid #444; + border-color: var(--border-color); + color: var(--text-color-active); + cursor:pointer; + text-decoration:none; +} +.layers_list .item{ + margin-bottom:2px; +} +.layers_list .layer_name{ + display:block; + padding:1px 5px 3px 5px; + height:19px; + width: calc(100% - 44px); + text-align: left; + overflow:hidden; + background-color:#989898; + background-color: var(--area-background-color); + border:1px solid #393939; + border-color: var(--border-color); + border-radius:3px; + cursor:pointer; + overflow:hidden; + font-size: 12px; + color:var(--text-color); + white-space: nowrap; +} +.layers_list .item.shorter .layer_name{ + width: calc(100% - 63px); +} +.layers_list .item.active .layer_name{ + background-color: var(--background-color-active); + color: var(--text-color-active); +} +.layers_list .arrow_down{ + position: relative; + float:left; + margin-right: 5px; + width:10px; + height:19px; + opacity: 0.4; +} +.layers_list .arrow_down:after{ + position: absolute; + content: ''; + left:0; + top:0; + bottom:0; + right:0; + filter: var(--menu-icons-filter); + background: url('images/icons/arrow-down.svg') no-repeat center center; + background-size: 12px auto; +} +.layers_list .visibility{ + position: relative; + float:left; + cursor:pointer; + padding:0px 3px 0px 3px; + margin-right: 5px; + width:20px; + height:19px; + opacity:0.1; + border: none; + background: transparent; + box-shadow: none; +} +.layers_list .visibility:after{ + position: absolute; + content: ''; + left:0; + top:0; + bottom:0; + right:0; + filter: var(--menu-icons-filter); + background: url('images/icons/view.svg') no-repeat center center; + background-size: 18px auto; +} +.layers_list .visible{ opacity:0.4; } +.layers_list .delete{ + float:right; + cursor:pointer; + padding:0px 3px 0px 3px; + width:12px; + height:19px; + margin-left: 5px; + background: transparent url(images/icons/delete.svg) no-repeat center center; + background-size: 10px 10px; + border: none; + box-shadow: none; +} +/* filters */ +.layers_list .filters{ + margin-bottom: 5px; +} +.layers_list .filter{ + margin-bottom: 2px; + margin-left: 30px; + opacity: 0.7; +} +.layers_list .filter .layer_name{ + position: relative; +} +.layers_list .filter .layer_name:after{ + position:absolute; + content:"fx"; + right: -4px; + top:1px; + bottom:0; + width: 20px; +} +/* Layer context menu */ +.layer_context_menu{ + display: none; + position: fixed; + z-index: 1000; + background: var(--section-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); + min-width: 150px; +} +.layer_context_menu ul{ + list-style: none; + margin: 0; + padding: 5px 0; +} +.layer_context_menu li{ + padding: 6px 15px; + cursor: pointer; + color: var(--text-color); + font-size: 13px; +} +.layer_context_menu li:hover{ + background: var(--background-color-active); + color: var(--text-color-active); +} +.layer_context_menu li.separator{ + height: 1px; + background: var(--border-color); + margin: 5px 10px; + padding: 0; + cursor: default; +} +.layer_context_menu li.separator:hover{ + background: var(--border-color); +} +.layer_scale{ + display:inline-block; + padding:1px 8px; + margin-right: 5px; + border:1px solid #444; + border-color: var(--border-color); + color: var(--text-color); + cursor:pointer; +} +/* My Library browser */ +.library-browser{ + max-height: 400px; + overflow-y: auto; + padding: 10px; +} +.library-category{ + margin-bottom: 20px; +} +.library-category h3{ + margin: 0 0 10px 0; + padding-bottom: 5px; + border-bottom: 1px solid var(--border-color); + color: var(--text-color); + font-size: 14px; +} +.library-items{ + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.library-item{ + width: 120px; + padding: 8px; + background: var(--area-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: 0.2s; +} +.library-item:hover{ + border-color: var(--background-color-active); + transform: scale(1.02); +} +.library-item img{ + width: 100%; + height: 80px; + object-fit: contain; + background: repeating-conic-gradient(#808080 0% 25%, #666 0% 50%) 50% / 10px 10px; + border-radius: 2px; +} +.library-item-name{ + margin-top: 5px; + font-size: 11px; + color: var(--text-color); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.library-item-actions{ + display: flex; + gap: 5px; + margin-top: 5px; +} +.library-item-actions button{ + flex: 1; + padding: 3px 5px; + font-size: 10px; + cursor: pointer; + border: 1px solid var(--border-color); + border-radius: 3px; + background: var(--section-background-color); + color: var(--text-color); +} +.library-item-actions .insert-btn{ + background: var(--background-color-active); + color: var(--text-color-active); +} +.library-item-actions .delete-btn:hover{ + background: #dc3545; + color: #fff; +} +.library-dialog .dialog_content{ + min-width: 500px; +} +.sidebar_right .label{ + display: inline-block; +} +.info .toggle.toggled{ + margin-bottom: -3px; +} +.block.details .row{ + clear:both; + margin-bottom: 4px; + min-height: 23px; +} +.block.details input[type="number"]{ + width: 70px; + padding: 3px 5px; + float: right; +} +.block.details .ui_color_input{ + width: 70px; + float: right; +} +.block.details .ui_color_input input{ + width: 100%; + height: 23px; +} +.block.details button.ui_toggle_button{ + width: 90px; + float: right; +} +.block.details select{ + width: calc(100% - 70px); + height: 23px; + float: right; +} +.block.details button{ + width: calc(100% - 70px); + height: 23px; + border: 1px solid #444; +} +.block.details button.reset{ + position: relative; + width: 25px; + float: right; + margin-right: 3px; + overflow: hidden; + opacity: 0.5; + color: transparent; +} +.block.details button.reset:after{ + position: absolute; + content: ''; + left:0; + top:0; + bottom:0; + right:0; + background: url(images/icons/refresh.svg) no-repeat center center; + background-size: auto 14px; + filter: var(--menu-icons-filter); +} +.block.details button.active{ + background-color: var(--background-color-active); + color: var(--text-color-active); +} +.details-content{ + height: 206px; + overflow-y: auto; +} + +@media screen and (max-width:700px){ + body{ + padding-top:50px; + } + .wrapper{ + top: 50px; + } + .sidebar_left{ + position: absolute; + left: -90px; + background: var(--background); + } + .sidebar_left.active{ + box-shadow: -5px 0px 10px 0px rgba(0,0,0,0.75); + left: 0; + z-index: 3; + } + .sidebar_right{ + position: absolute; + height: 100%; + right: -210px; + background: var(--background); + } + .sidebar_right.active{ + box-shadow: -5px 0px 10px 0px rgba(0,0,0,0.75); + right: 0; + margin-right: 0; + } +} + +/* ========== content ======================================================= */ + +.ruler_left{ + display: none; + position: absolute; + left:0; + top: 20px; + background-color: #ccc; +} +.ruler_top{ + display: none; + position: absolute; + left: 20px; + top:0; + background-color: #ccc; +} +.middle_area{ + position: relative; + -ms-grid-row: 2; + -ms-grid-column: 2; + grid-area: main; +} +.main_wrapper{ + position:absolute; + top:0; + right:0; + bottom:0; + left:0; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; +} +.middle_area.has-ruler .main_wrapper{ + top: 20px; + left: 20px; +} +.canvas_wrapper{ + position:relative; +} +.canvas_wrapper canvas{ + position: absolute; + box-sizing: content-box; + font-kerning: normal !important; +} +.loaded .canvas_wrapper canvas{ + border: 1px solid var(--border-color); +} +#mouse{ + position:absolute; + pointer-events:none; + width:10px; + height:10px; + z-index:10; +} +#mouse.rect{ + border:1px solid rgba(0,0,0,0.5); +} +#mouse.circle{ + border:1px solid rgba(0,0,0,0.5); + border-radius:50%; +} +.transparent-grid{ + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + /*background: url(images/icons/grid.png) repeat top left;*/ + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAQElEQVQ4T2N89+7dfwYigKCgIBGqGBgYRw3EGU6jYYgzaIZAsvn//z9ROeX9+/fE5ZRRA3GG02gY4s4pgz7ZAAAnSWvHPkHXaAAAAABJRU5ErkJggg==') repeat top left; + z-index:1; + image-rendering: pixelated; /* disable antialiasing */ +} +.transparent-grid.white{ + background:white; +} +.transparent-grid.green{ + background: #5be471; +} +.transparent-grid.grey{ + background: #dfdfdf; +} +canvas{ + position:relative; + z-index:2; +} +#canvas_back{ + position: absolute; + background-color:#ffffff; + outline: none; +} +#canvas_grid{ + pointer-events:none; +} +.group{ + border:1px solid #999999; + margin: 5px 0px 5px 0px; + padding:5px 8px; +} +.flex-container{ + display: flex; + flex-wrap: wrap; +} +.flex-container .item{ + flex: auto; + margin: 2px 0; + width: 150px; +} +.flex-container .item:empty{ + height: 0; + border: none; +} +/* Alertify toast notification styling */ +.alertify-notifier .ajs-message { + background-color: #333; + color: #fff; + border-radius: 4px; + padding: 10px 15px; + box-shadow: 0 2px 10px rgba(0,0,0,0.3); +} +.alertify-notifier .ajs-message.ajs-success { + background-color: #28a745; + color: #fff; +} +.alertify-notifier .ajs-message.ajs-error { + background-color: #dc3545; + color: #fff; +} +.alertify-notifier .ajs-message.ajs-warning { + background-color: #ffc107; + color: #000; +} + +/* Alertify dialog styling - fix text color issues */ +.alertify .ajs-dialog { + background-color: #3a3f44; + color: #f4f3f3; + border-radius: 6px; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); +} +.alertify .ajs-header { + background-color: #4a5058; + color: #f4f3f3; + border-bottom: 1px solid #555; + padding: 10px 15px; + font-weight: bold; +} +.alertify .ajs-body { + color: #f4f3f3; + padding: 15px; +} +.alertify .ajs-body .ajs-content { + color: #f4f3f3; +} +.alertify .ajs-footer { + background-color: #3a3f44; + border-top: 1px solid #555; + padding: 10px 15px; +} +.alertify .ajs-footer .ajs-buttons .ajs-button { + background-color: #4a5058; + color: #f4f3f3; + border: 1px solid #666; + border-radius: 4px; + padding: 6px 16px; + margin: 0 4px; + cursor: pointer; +} +.alertify .ajs-footer .ajs-buttons .ajs-button:hover { + background-color: #5a6068; +} +.alertify .ajs-footer .ajs-buttons .ajs-button.ajs-ok { + background-color: #28a745; + border-color: #28a745; +} +.alertify .ajs-footer .ajs-buttons .ajs-button.ajs-ok:hover { + background-color: #218838; +} +.alertify .ajs-input { + background-color: #2a2f34; + color: #f4f3f3; + border: 1px solid #555; + padding: 8px; + border-radius: 4px; + width: 100%; +} +.alertify .ajs-input::placeholder { + color: #999; +} +.effectsPreview{ + cursor: pointer; + background-color: #ddd; +} + +@media screen and (max-width:550px){ + .canvas_wrapper{ + margin-left: 0px; + } +} +@media screen and (max-height: 690px){ + .sidebar_left{ + width: 75px; + } +} +@media screen and (max-height:450px){ + .sidebar_left{ + width: 88px; + } +} + +/* ========== dialogs ======================================================= */ + +#dialog_color_picker_group { + width: 60%; +} +#dialog_color_channel_group { + width: 40%; + margin-left: 1rem; +} + +@media screen and (max-width: 450px) { + #dialog_color_picker .ui_flex_group { + flex-wrap: wrap; + } + #dialog_color_picker_group { + width: 100%; + } + #dialog_color_channel_group { + width: 100%; + margin-left: 0; + margin-top: 1rem; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/css/menu.css b/paintplus/frontend/src/css/menu.css new file mode 100644 index 0000000..037bd1b --- /dev/null +++ b/paintplus/frontend/src/css/menu.css @@ -0,0 +1,202 @@ +:root { + --menu-dropdown-background-color: #ffffff; + --menu-dropdown-border-color: #49844d; + --menu-dropdown-text-color: #2d2b2b; + --menu-dropdown-text-muted-color: #aaaaaa; + --menu-dropdown-hover-background-color: #adecab; + --menu-dropdown-hover-text-color: #2d2d2d; + --menu-dropdown-divider-color: #e5e5e5; +} + +.sr_only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.main_menu { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; +} +.main_menu > ul.menu_bar { + display: flex; + flex-direction: row; + list-style: none; + padding: 0; + margin: 0; + height: 30px; + padding-left: 10px; + background: var(--menu-background-color); +} +.main_menu > ul.menu_bar > li { + padding: 0; + overflow: hidden; + height: 100%; +} +.main_menu > ul.menu_bar > li > a { + display: flex; + align-items: center; + font-size: 12px; + color: var(--menu-text-color); + text-decoration: none; + padding: 0 10px; + height: 100%; +} +.main_menu > ul.menu_bar > li > a::-moz-focus-inner { + border: 0; +} +.main_menu > ul.menu_bar > li > a:focus { + outline: none; + box-shadow: 0 -3px var(--menu-dropdown-background-color) inset; +} +.main_menu > ul.menu_bar > li > a:hover { + background: var(--menu-dropdown-hover-background-color); + box-shadow: none; + color: var(--menu-dropdown-hover-text-color); +} +.main_menu > ul.menu_bar > li > a[aria-expanded="true"] { + background: var(--menu-dropdown-background-color); + box-shadow: none; + color: var(--menu-dropdown-text-color); +} +.main_menu > ul.menu_bar > li > a > * { + pointer-events: none; +} + +.main_menu > ul.menu_dropdown { + display: flex; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + list-style: none; + padding: 0; + margin: 0; + overflow-x: hidden; + overflow-y: auto; + min-width: 150px; + box-shadow: 0 0 0 1px var(--menu-dropdown-border-color); + background: var(--menu-dropdown-background-color); +} +.main_menu > ul.menu_dropdown > li { + padding: 0; +} +.main_menu > ul.menu_dropdown > li > hr { + background: none; + border: 1px solid var(--menu-dropdown-divider-color); + border-bottom: none; + margin: 0; +} +.main_menu > ul.menu_dropdown > li > a { + display: flex; + flex-direction: row; + align-items: center; + position: relative; + height: 30px; + padding: 0 10px; + font-size: 12px; + line-height: 30px; + text-decoration: none; + color: var(--menu-dropdown-text-color); +} +.main_menu > ul.menu_dropdown > li > ::-moz-focus-inner { + border: 0; +} +.main_menu > ul.menu_dropdown > li > a:focus { + outline: none; + box-shadow: 0 0 0 2px var(--menu-dropdown-hover-background-color) inset; +} +.main_menu > ul.menu_dropdown > li > a:hover { + background: var(--menu-dropdown-hover-background-color); + box-shadow: none; + color: var(--menu-dropdown-hover-text-color); +} +.main_menu > ul.menu_dropdown > li > a[aria-expanded="true"] { + background: var(--menu-dropdown-hover-background-color); + box-shadow: none; + color: var(--menu-dropdown-hover-text-color); +} +.main_menu > ul.menu_dropdown > li > a[aria-haspopup="true"]::after { + position: absolute; + content: ">"; + right: 9px; + width: 5px; + transform: scaleY(2); + color: #808080; +} +.main_menu > ul.menu_dropdown > li > a[aria-haspopup="true"] > .name { + margin-right: 8px; +} +.main_menu > ul.menu_dropdown > li > a[target="_blank"]::after { + content: ""; + width: 10px; + height: 10px; + margin-left: 5px; + background: url('images/icons/external.png') no-repeat center center; + background-size: auto 8px; + opacity: 0.3; +} +.main_menu > ul.menu_dropdown > li > a > * { + pointer-events: none; +} +.main_menu > ul.menu_dropdown > li > a > .name { + flex-grow: 1; + overflow: hidden; + white-space: nowrap; +} +.main_menu > ul.menu_dropdown > li > a > .shortcut { + flex-shrink: 1; + color: var(--menu-dropdown-text-muted-color); +} + + +.mobile_menu { + display: none; + position: absolute; + width: 100%; + top: 0; +} +.left_mobile_menu, .right_mobile_menu { + position: absolute; + width: 50px; + height: 50px; + display: block; + top: 0; + z-index: 200; + border: 0; + outline: 0; + cursor: pointer; + background-color: transparent; +} +.left_mobile_menu:after, .right_mobile_menu:after { + position: absolute; + content: ''; + left:0; + top:0; + bottom:0; + right:0; + filter: var(--mobile-menu-toggle-filter); + background: url('images/icons/menu.svg') no-repeat center center; + background-size: auto 26px; +} +.left_mobile_menu { left:0; } +.right_mobile_menu { right:0; } + +@media screen and (max-width:700px) { + .mobile_menu { + display: block; + } + .main_menu > ul.menu_bar { + height: 50px; + padding-left: 50px; + padding-right: 50px; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/css/popup.css b/paintplus/frontend/src/css/popup.css new file mode 100644 index 0000000..5a72e74 --- /dev/null +++ b/paintplus/frontend/src/css/popup.css @@ -0,0 +1,408 @@ +#popups:not(:empty) { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} +#popups .popup { + position:fixed; + display:none; + top: 15vh; + left: calc(100vw / 2); + transform: translate(-50%, 0); + background-color: #7A838B; + background-color: var(--block-background-color); + border: 1px solid rgba(0, 0, 0, 0.5); + border: 1px solid var(--border-color); + width: 90vw; + max-width: 500px; + max-height: calc(80vh); + margin:0px auto 0px auto; + padding: 4rem 0 5rem 0; + box-shadow: 0 0 0 4000px rgba(0,0,0,0.3), 0 0 20px rgba(0,0,0,0.5); + z-index: 100; + font-size: 13px; + overflow: hidden; +} +#popups .popup.wide{ + max-width: 840px; +} +#popups .popup a{ + color: var(--link-color); +} +#popups .popup h2{ + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + margin: 0; + height: 4rem; + line-height: 4rem; + padding: 0 1rem; + font-size: 1.8rem; + background-color: rgba(255, 255, 255, 0.3); + background-color: var(--header-background-color); + z-index: 0; + cursor:move; +} +#popups .popup .dialog_content { + overflow-y: auto; + max-height: calc(80vh - 11rem); + padding: 1rem; +} +#popups .popup .buttons{ + position: absolute; + background-color: var(--block-background-color); + bottom: 0; + left: 0; + right: 0; + height: 5rem; + line-height: 4rem; + margin: 0; + padding: .5rem 0; + text-align: center; + border-top: 1px solid var(--header-background-color); + z-index: 3; +} +#popups .popup .close{ + position: absolute; + right: 0; + top: 0; + min-width: 0; + padding: 5px; + line-height: 0.5; + font-size: 16px; + margin-top: 10px; + margin-right: 10px; + border: none; + background: none; + z-index: 1; +} +#popups .popup input[type="range"]{ + margin:0; + width: 100%; +} +#popups .popup table{ + box-sizing: border-box; + width: 100%; +} +#popups .popup td, #popups .popup th{ + height: 25px; +} +#popups .popup td{ + vertical-align: middle; +} +#popups .popup th{ + text-align:left; + padding: 5px 5px 5px 0; + width: 130px; +} +#popups .popup textarea{ + color: var(--input-text-color); + width:100%; + border:1px solid #393939; + padding-left:5px; +} +#popups .popup .button{ + margin: 0 3px; + background-color: rgba(255, 255, 255, 0.2); + background-color: var(--button-background-color); + min-width:60px; + border:1px solid rgba(0, 0, 0, 0.5); + border:1px solid var(--border-color); + padding: 5px 10px; +} +#popups .popup input[type="text"], #popups .popup input[type="number"], #popups .popup textarea{ + width:100%; +} +#popups .popup input[type="number"]{ + width:100px; +} +#popups .popup input[type="radio"], #popups .popup input[type="checkbox"]{ + margin-left: 0; +} +#popups .popup label span{ + color:var(--text-color-muted); +} +#popups .popup .checkbox label{ + margin-top: 5px; + color:var(--text-color-muted); +} +#popups .popup .preview_container{ + margin-top:10px; + margin-bottom:15px; + text-align: center; +} +#popups .popup .preview_canvas_left{ + position:relative; + margin:0 5px 5px 0; + border:1px solid #393939; + display: inline-block; + vertical-align: top; +} +#popups .popup .preview_canvas_post_back{ + position:absolute; + border:1px solid #393939; + background-color:#ffffff; +} +#popups .popup .preview_canvas_post{ + position:relative; + border:1px solid #393939; +} +#popups .popup .canvas_preview_container{ + position:relative; + display: inline-block; + vertical-align: top; +} +#popups .popup .radios label{ + display: inline-block; + margin-right: 10px; +} +#popups .popup .range_value{ + padding-left:10px; + width:50px; +} +#popups .popup .long_text_value{ + font-size: 12px; +} +#popups .popup .preview-item-title{ + text-align: center; + max-width: 150px; +} +#popups .popup .field_comment{ + display: inline-block; + margin-left: 10px; + opacity: 0.5; +} + +#popups .popup .selection_card { + background: var(--input-background-color); + display: block; + width: 100%; + padding: 0; + border-bottom: 0.1rem solid var(--input-border-color); + overflow: hidden; + position: relative; +} +#popups .popup .selection_card:first-child { + margin-top: 1rem; + border-radius: var(--input-border-radius) var(--input-border-radius) 0 0; +} +#popups .popup .selection_card:last-child { + border-radius: 0 0 var(--input-border-radius) var(--input-border-radius); + border-bottom: none; +} +#popups .popup .selection_card > input[type="checkbox"] { + flex-grow: 0; + flex-shrink: 0; + margin: 0; + cursor: pointer; + position: absolute; + top: 50%; + left: 1.5rem; + transform: translateY(-50%) scale(1.5); +} +#popups .popup .selection_card > input[type="checkbox"] + label { + display: block; + width: 100%; + flex-grow: 1; + flex-shrink: 1; + margin: 0; + padding: 1rem 0.5rem 1rem 5.5rem; + cursor: pointer; +} +#popups .popup .selection_card > input[type="checkbox"] + label:hover { + background: var(--input-background-color-hover); +} +#popups .popup .selection_card .font_preview { + font-size: 1.6rem; + height: 2.5rem; + line-height: 2.5rem; + white-space: nowrap; +} + +#popups .popup .pagination { + display: flex; + text-align: center; + margin: 1rem 0 0 0; +} +#popups .popup .pagination button { + flex-grow: 0; + height: 2.8rem; + line-height: 2.8rem; + border-radius: 0; + margin-left: -1px; + min-width: 3.3rem; +} +#popups .popup .pagination button:first-child { + border-radius: var(--button-border-radius) 0 0 var(--button-border-radius); + margin-left: auto; +} +#popups .popup .pagination button:last-child { + border-radius: 0 var(--button-border-radius) var(--button-border-radius) 0; + margin-right: auto; +} + +/* Shape/Library Tabs */ +#popups .popup .shape-tabs { + display: flex; + gap: 0; + margin-bottom: 1rem; + border-bottom: 2px solid var(--border-color); +} + +#popups .popup .shape-tab { + padding: 0.8rem 1.5rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + color: var(--text-color-muted); + font-size: 1rem; + transition: color 0.2s, border-color 0.2s; +} + +#popups .popup .shape-tab:hover { + color: var(--text-color); +} + +#popups .popup .shape-tab.active { + color: var(--link-color); + border-bottom-color: var(--link-color); +} + +#popups .popup .library-loading { + text-align: center; + padding: 2rem; + color: var(--text-color-muted); +} + +/* My Library Browser Styles */ +#popups .popup .library-browser { + max-height: calc(60vh - 100px); + overflow-y: auto; +} + +#popups .popup .library-categories { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +#popups .popup .library-category h3 { + color: var(--text-color); + font-size: 1.4rem; + margin-bottom: 0.8rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border-color); +} + +#popups .popup .library-items { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +#popups .popup .library-item { + display: flex; + flex-direction: column; + align-items: center; + width: 120px; + padding: 0.8rem; + background: var(--input-background-color); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s; +} + +#popups .popup .library-item:hover { + background: var(--input-background-color-hover); + border-color: var(--link-color); +} + +#popups .popup .library-item img { + width: 100px; + height: 80px; + object-fit: contain; + background: repeating-conic-gradient(#666 0% 25%, #888 0% 50%) 50% / 10px 10px; + border-radius: 2px; + margin-bottom: 0.5rem; +} + +#popups .popup .library-item-name { + font-size: 0.85rem; + text-align: center; + color: var(--text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: 100%; + margin-bottom: 0.5rem; +} + +#popups .popup .library-item-actions { + display: flex; + gap: 0.5rem; +} + +#popups .popup .library-item-actions button { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + background: var(--button-background-color); + border: 1px solid var(--border-color); + border-radius: 3px; + cursor: pointer; + color: var(--text-color); +} + +#popups .popup .library-item-actions .insert-btn { + background: #2a6d2a; +} + +#popups .popup .library-item-actions .insert-btn:hover { + background: #3a8d3a; +} + +#popups .popup .library-item-actions .delete-btn { + background: #6d2a2a; +} + +#popups .popup .library-item-actions .delete-btn:hover { + background: #8d3a3a; +} + +/* Library empty state */ +#popups .popup .library-empty { + text-align: center; + padding: 2rem; + color: var(--text-color-muted); +} + +@media screen and (max-width:500px){ + #popups .popup { + max-height: calc(80vh - 20px); /* mobile phones has bottom menu */ + } + #popups .popup tr{ + display: block; + margin-bottom: 10px; + } + #popups .popup td, #popups .popup th{ + display: block; + width: 100%; + height: auto; + padding: 5px; + } + #popups .popup th{ + padding: 5px 5px 0px 5px; + } + #popups .popup td{ + padding: 5px 5px 5px 5px; + } + #popups .popup .range_value{ + display: none; + } +} diff --git a/paintplus/frontend/src/css/print.css b/paintplus/frontend/src/css/print.css new file mode 100644 index 0000000..6b09327 --- /dev/null +++ b/paintplus/frontend/src/css/print.css @@ -0,0 +1,34 @@ +@media print{ + body{ + background:none !important; + background: #fff; + background-color: #fff; + font-family: Arial,Helvetica,Verdana; + width:auto !important; + padding:5px !important; + font-size: 12px; + } + progress, + .menu, + .sidebar_left, + .sidebar_right, + .submenu, + .main_menu{ + display: none; + height: 0; + width: 0; + } + .main_wrapper{ + margin:0px; + padding:0px; + } + canvas{ + border:0px; + position: absolute; + top:0px; + left:0px; + } + .canvas_wrapper canvas{ + border:0; + } +} diff --git a/paintplus/frontend/src/css/reset.css b/paintplus/frontend/src/css/reset.css new file mode 100644 index 0000000..27f4333 --- /dev/null +++ b/paintplus/frontend/src/css/reset.css @@ -0,0 +1,230 @@ +:root { + /* original - default */ + --background: #666d6f; + --text-color: #f4f3f3; + --text-color-muted: #c1c1c1; + --text-color-red: #e38282; + --text-color-green: #8bdb8b; + --text-color-blue: #a4a4ff; + --link-color: #9ffda5; + --section-background-color: #323a3c; + --area-background-color: #464d4f; + --block-background-color: #464d4f; + --header-background-color: #373d3f; + --button-background-color: #2f3739; + --button-background-color-hover: #75df72; + --button-background-color-active: #4d5153; + --button-shadow-color: rgba(0, 0, 0, 0.3); + --button-text-color-active: #adecab; + --button-border-radius: .4rem; + --button-toggle-background-color: #575f62; + --button-toggle-background-color-hover: #575f62; + --input-background-color: #2f3739; + --input-background-color-hover: #383f44; + --input-text-color: #f4f3f3; + --input-border-color: #0f0f0f; + --input-border-color-active: #70996e; + --input-border-radius: .4rem; + --input-group-border-color: #323a3c; + --menu-background-color: #222; + --menu-icons-filter: invert(1); + --menu-icons-filter-active: none; + --menu-text-color: #cccccc; + --number-input-arrow-width: 2rem; + --background-color-active: #adecab; + --background-color-hover: #575f62; + --text-color-active: #215b2a; + --border-color: #727677; + --scrollbar-track-color: #464d4f; + --scrollbar-thumb-color: #2f3739; + --mobile-menu-toggle-filter: invert(1); +} +body.theme-light{ + /* light */ + --background: #f9f9fa; + --text-color: #0c0c0d; + --text-color-muted: #444444; + --text-color-red: #bb2424; + --text-color-green: #2b882b; + --text-color-blue: #5454ca; + --link-color: #000080; + --section-background-color: #eaeaea; + --area-background-color: #d9d9d9; + --block-background-color: #eaeaea; + --header-background-color: #dbdbdb; + --button-background-color: #f9f9fa; + --button-background-color-hover: #ddd; + --button-background-color-active: #f3f3f3; + --button-text-color-active: #59aed8; + --button-shadow-color: rgba(0, 0, 0, 0.1); + --button-toggle-background-color: #b7b7b7; + --button-toggle-background-color-hover: #b7b7b7; + --input-background-color: #ffffff; + --input-background-color-hover: #ddd; + --input-text-color: #0c0c0d; + --input-border-color: #ccc; + --input-border-color-active: #59aed8; + --input-group-border-color: #c4c4c4; + --menu-background-color: #eaeaea; + --menu-icons-filter: none; + --menu-icons-filter-active: invert(1); + --menu-text-color: #333333; + --menu-dropdown-hover-background-color: #a3dbf7; + --menu-dropdown-border-color: #15439b; + --background-color-active: #a3dbf7; + --background-color-hover: #c4c4c4; + --text-color-active: #15439b; + --border-color: #c1c1c1; + --scrollbar-track-color: #f9f9fa; + --scrollbar-thumb-color: #919090; + --mobile-menu-toggle-filter: none; +} +body.theme-green{ + /* green */ + --background: #050702; + --text-color: #acc3a9; + --text-color-muted: #80937d; + --link-color: #9ffda5; + --section-background-color: #1c2e04; + --area-background-color: #3b5f11; + --block-background-color: #3b5f11; + --header-background-color: #2b460f; + --button-background-color: #2e4a0d; + --button-background-color-hover: #58960e; + --button-background-color-active:#2b460f; + --button-text-color-active: #ccc; + --button-toggle-background-color: #243e05; + --button-toggle-background-color-hover: #243e05; + --input-background-color: #ffffff; + --input-background-color-hover: #ddd; + --input-text-color: #0c0c0d; + --input-border-color: #ccc; + --menu-background-color: #1c2e04; + --menu-icons-filter: invert(1); + --menu-icons-filter-active: none; + --menu-text-color: #acc3a9; + --background-color-active: #58960e; + --background-color-hover: #58960e; + --text-color-active: #acc3a9; + --border-color: #4d6b1e; + --scrollbar-track-color: #050702; + --scrollbar-thumb-color: #80937d; + --mobile-menu-toggle-filter: invert(1); +} + +*{ + box-sizing: border-box; + background-repeat: no-repeat; +} +html { + font-size: 10px; /* Base is 10px for easy REM calculation */ +} +body{ + margin: 0; + padding: 30px 0 0 0; + background-color: #424F5A; + background: var(--background); + font-size: 1.3rem; + font-family: Arial, Helvetica, sans-serif; + color: var(--text-color); + line-height: 1.4; + font-weight: normal; + overflow: hidden; +} +canvas{ + outline: none; + /* disable select canvas */ + -webkit-touch-callout: none; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; +} +img{ + border: none; +} +td, th{ + vertical-align:top; +} +table{ + border: 0; + margin: 0; + padding: 0; + vertical-align: baseline; + border-collapse: collapse; + border-spacing: 0; + width:100%; +} +hr{ + border-color: rgba(0,0,0,0.3); + border-bottom: 0; +} +input[type="text"], select, input[type="number"], textarea{ + background: var(--input-background-color); + border: 1px solid var(--input-border-color); + border-radius: var(--input-border-radius); + color: var(--input-text-color); + padding: 3px 5px; + font-size: 13px; +} +input:disabled { + opacity: 0.3; +} +select{ + padding: 2px 4px; +} +input[type="range"]{ + margin-left: 0; + width:100%; +} +button, input[type="button"]{ + border-radius: var(--button-border-radius); + box-shadow: 0 1px 2px 0 var(--button-shadow-color), 0 1px 0 0 rgba(255, 255, 255, 0.1) inset; + cursor: pointer; + border: 1px solid var(--border-color); + background-color: var(--button-background-color); + color: var(--text-color); +} +button:hover, input[type="button"]:hover{ + background-color: var(--button-background-color-hover); +} +button:disabled, input[type="button"]:disabled{ + visibility:hidden; +} +button[aria-pressed="true"], input[type="button"][aria-pressed="true"]{ + background-color: var(--button-background-color-active); + color: var(--button-text-color-active); + box-shadow: 0 1px 2px 0 var(--button-shadow-color), 0 1px 1px 1.5px rgba(58, 40, 40, 0.1) inset, 0 -1px 0 0 var(--button-text-color-active) inset; +} +button[aria-pressed="true"]:hover, input[type="button"][aria-pressed="true"]:hover{ + background-color: var(--button-background-color-hover); +} +button.ui_toggle_button:hover{ + background-color: var(--button-toggle-background-color-hover); +} +label{ + display: inline-block; + vertical-align: top; + margin-top: 7px; +} +::-webkit-scrollbar { + width: 12px; + height: 12px; +} +::-webkit-scrollbar-track-piece { + background: rgba(0,0,0,0.3); +} +::-webkit-scrollbar-thumb { + background: rgba(0,0,0,0.6); +} +@supports (zoom:2) { + input[type="radio"], input[type=checkbox]{ + zoom: 1.5; + } +} +@supports not (zoom:2) { + input[type="radio"], input[type=checkbox]{ + transform: scale(1.5); + transform-origin: left center; + margin: 8px 12px 8px 0; + } +} diff --git a/paintplus/frontend/src/css/utility.css b/paintplus/frontend/src/css/utility.css new file mode 100644 index 0000000..6a387e7 --- /dev/null +++ b/paintplus/frontend/src/css/utility.css @@ -0,0 +1,81 @@ + +/* Common input label sizes */ +.label_width_character { + width: 100%; + max-width: 2.88rem; + overflow: hidden; + flex-shrink: 0; +} +.label_width_small { + width: 100%; + max-width: 6.4rem; + overflow: hidden; +} +.label_width_medium { + width: 100%; + max-width: 10.4rem; + overflow: hidden; +} + +/* Font color utility */ +.text_red { color: var(--text-color-red); } +.text_green { color: var(--text-color-green); } +.text_blue { color: var(--text-color-blue); } +.text_muted { color: var(--text-color-muted); } + +/* + Size inputs based on the number of "w" characters that could fit in the input. "w" is usually the widest character. + This is a rough estimate since all characters vary in width. For example an input with numbers + usually fits way more characters than an input with letters. + "cw" means character width +*/ +.input_cw_1, .input_cw_2, .input_cw_3, .input_cw_4, .input_cw_5, +.input_cw_6, .input_cw_7, .input_cw_8, .input_cw_9, .input_cw_10 +.input_cw_11, .input_cw_12, .input_cw_13, .input_cw_14, .input_cw_15 { + width: 100%; +} +.input_cw_1 { max-width: 2.25rem; } +.input_cw_2 { max-width: 3.25rem; } +.input_cw_3 { max-width: 4.25rem; } +.input_cw_4 { max-width: 5.25rem; } +.input_cw_5 { max-width: 6.25rem; } +.input_cw_6 { max-width: 7.25rem; } +.input_cw_7 { max-width: 8.25rem; } +.input_cw_8 { max-width: 9.25rem; } +.input_cw_9 { max-width: 10.25rem; } +.input_cw_10 { max-width: 11.25rem; } +.input_cw_11 { max-width: 12.25rem; } +.input_cw_12 { max-width: 13.25rem; } +.input_cw_13 { max-width: 14.25rem; } +.input_cw_14 { max-width: 15.25rem; } +.input_cw_15 { max-width: 16.25rem; } +input[type="number"].input_cw_1 { max-width: 4.25rem; } +input[type="number"].input_cw_2 { max-width: 5.25rem; } +input[type="number"].input_cw_3 { max-width: 6.25rem; } +input[type="number"].input_cw_4 { max-width: 7.25rem; } +input[type="number"].input_cw_5 { max-width: 8.25rem; } +input[type="number"].input_cw_6 { max-width: 9.25rem; } +input[type="number"].input_cw_7 { max-width: 10.25rem; } +input[type="number"].input_cw_8 { max-width: 11.25rem; } +input[type="number"].input_cw_9 { max-width: 12.25rem; } +input[type="number"].input_cw_10 { max-width: 13.25rem; } +input[type="number"].input_cw_11 { max-width: 14.25rem; } +input[type="number"].input_cw_12 { max-width: 15.25rem; } +input[type="number"].input_cw_13 { max-width: 16.25rem; } +input[type="number"].input_cw_14 { max-width: 17.25rem; } +input[type="number"].input_cw_15 { max-width: 18.25rem; } +.ui_number_input.input_cw_1 { max-width: calc(2.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_2 { max-width: calc(3.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_3 { max-width: calc(4.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_4 { max-width: calc(5.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_5 { max-width: calc(6.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_6 { max-width: calc(7.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_7 { max-width: calc(8.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_8 { max-width: calc(9.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_9 { max-width: calc(10.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_10 { max-width: calc(11.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_11 { max-width: calc(12.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_12 { max-width: calc(13.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_13 { max-width: calc(14.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_14 { max-width: calc(15.25rem + var(--number-input-arrow-width)); } +.ui_number_input.input_cw_15 { max-width: calc(16.25rem + var(--number-input-arrow-width)); } \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/_README.md b/paintplus/frontend/src/js/actions/_README.md new file mode 100644 index 0000000..f381cd8 --- /dev/null +++ b/paintplus/frontend/src/js/actions/_README.md @@ -0,0 +1,5 @@ +# Managing Undo History with Actions + +More information on wiki page: + +https://github.com/viliusle/miniPaint/wiki/Undo-Redo-system \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/activate-tool.js b/paintplus/frontend/src/js/actions/activate-tool.js new file mode 100644 index 0000000..3b8d7e5 --- /dev/null +++ b/paintplus/frontend/src/js/actions/activate-tool.js @@ -0,0 +1,145 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +export class Activate_tool_action extends Base_action { + /** + * Groups multiple actions together in the undo/redo history, runs them all at once. + */ + constructor(key, ignore_same_tool) { + super('activate_tool', 'Activate Tool'); + this.ignore_same_tool = !!ignore_same_tool; + this.key = key; + this.old_key = null; + this.tool_leave_actions = null; + this.tool_activate_actions = null; + } + + async do() { + super.do(); + const key = this.key; + this.old_key = app.GUI.GUI_tools.active_tool; + + if (this.key !== this.old_key || this.ignore_same_tool) { + + //reset last + document.querySelector('#tools_container .' + this.old_key).classList.remove("active"); + + //send exit event to old previous tool + if (config.TOOL.on_leave != undefined) { + var moduleKey = config.TOOL.name; + var functionName = config.TOOL.on_leave; + this.tool_leave_actions = app.GUI.GUI_tools.tools_modules[moduleKey].object[functionName](); + if (this.tool_leave_actions) { + for (let action of this.tool_leave_actions) { + await action.do(); + } + } + } + + //change active + app.GUI.GUI_tools.active_tool = key; + document.querySelector('#tools_container .' + app.GUI.GUI_tools.active_tool) + .classList.add("active"); + for (let i in config.TOOLS) { + if (config.TOOLS[i].name == app.GUI.GUI_tools.active_tool) { + config.TOOL = config.TOOLS[i]; + } + } + + //check module + if (app.GUI.GUI_tools.tools_modules[key] == undefined) { + alertify.error('Tools class not found: ' + key); + return; + } + + //set default cursor + const mainWrapper = document.getElementById('main_wrapper'); + const defaultCursor = config.TOOL && config.TOOL.name === 'text' ? 'text' : 'default'; + if (mainWrapper.style.cursor != defaultCursor) { + mainWrapper.style.cursor = defaultCursor; + } + + app.GUI.GUI_tools.show_action_attributes(); + app.GUI.GUI_tools.Helper.setCookie('active_tool', app.GUI.GUI_tools.active_tool); + } + + //send activate event to new tool + if (config.TOOL.on_activate != undefined) { + var moduleKey = config.TOOL.name; + var functionName = config.TOOL.on_activate; + this.tool_activate_actions = app.GUI.GUI_tools.tools_modules[moduleKey].object[functionName](); + if (this.tool_activate_actions) { + for (let action of this.tool_activate_actions) { + await action.do(); + } + } + } + + config.need_render = true; + } + + async undo() { + super.undo(); + + // Undo activate actions + if (this.tool_activate_actions) { + for (let action of this.tool_activate_actions) { + await action.undo(); + action.free(); + } + this.tool_activate_actions = null; + } + + //reset last + document.querySelector('#tools_container .' + this.key) + .classList.remove("active"); + + //change active + app.GUI.GUI_tools.active_tool = this.old_key; + document.querySelector('#tools_container .' + app.GUI.GUI_tools.active_tool) + .classList.add("active"); + for (let i in config.TOOLS) { + if (config.TOOLS[i].name == app.GUI.GUI_tools.active_tool) { + config.TOOL = config.TOOLS[i]; + } + } + + app.GUI.GUI_tools.show_action_attributes(); + app.GUI.GUI_tools.Helper.setCookie('active_tool', app.GUI.GUI_tools.active_tool); + + //set default cursor + const mainWrapper = document.getElementById('main_wrapper'); + const defaultCursor = config.TOOL && config.TOOL.name === 'text' ? 'text' : 'default'; + if (mainWrapper.style.cursor != defaultCursor) { + mainWrapper.style.cursor = defaultCursor; + } + + // Undo leave actions + if (this.tool_leave_actions) { + for (let action of this.tool_leave_actions) { + await action.undo(); + action.free(); + } + this.tool_leave_actions = null; + } + + config.need_render = true; + } + + free() { + if (this.tool_activate_actions) { + for (let action of this.tool_activate_actions) { + action.free(); + } + this.tool_activate_actions = null; + } + if (this.tool_leave_actions) { + for (let action of this.tool_leave_actions) { + action.free(); + } + this.tool_leave_actions = null; + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/add-layer-filter.js b/paintplus/frontend/src/js/actions/add-layer-filter.js new file mode 100644 index 0000000..07d3321 --- /dev/null +++ b/paintplus/frontend/src/js/actions/add-layer-filter.js @@ -0,0 +1,67 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Add_layer_filter_action extends Base_action { + /** + * register new live filter + * + * @param {int} layer_id + * @param {string} name + * @param {object} params + */ + constructor(layer_id, name, params, filter_id) { + super('add_layer_filter', 'Add Layer Filter'); + if (layer_id == null) + layer_id = config.layer.id; + this.layer_id = parseInt(layer_id); + this.name = name; + this.params = params; + this.filter_id = filter_id; + this.reference_layer = null; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + var filter = { + id: this.filter_id, + name: this.name, + params: this.params, + }; + if(this.filter_id) { + //update + for(var i in this.reference_layer.filters) { + if(this.reference_layer.filters[i].id == this.filter_id){ + this.reference_layer.filters[i] = filter; + break; + } + } + } + else{ + //insert + filter.id = Math.floor(Math.random() * 999999999) + 1; // A good UUID library would + this.reference_layer.filters.push(filter); + } + config.need_render = true; + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + if (this.reference_layer) { + this.reference_layer.filters.pop(); + this.reference_layer = null; + } + config.need_render = true; + app.GUI.GUI_layers.render_layers(); + } + + free() { + this.reference_layer = null; + this.params = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/autoresize-canvas.js b/paintplus/frontend/src/js/actions/autoresize-canvas.js new file mode 100644 index 0000000..28fcbdf --- /dev/null +++ b/paintplus/frontend/src/js/actions/autoresize-canvas.js @@ -0,0 +1,100 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; +import Tools_settings_class from './../modules/tools/settings.js'; + +export class Autoresize_canvas_action extends Base_action { + /** + * autoresize canvas to layer size, based on dimensions, up - always, if 1 layer - down. + * + * @param {int} width + * @param {int} height + * @param {int} layer_id + * @param {boolean} can_automate + */ + constructor(width, height, layer_id, can_automate = true, ignore_same_size = false) { + super('autoresize_canvas', 'Auto-resize Canvas'); + this.Tools_settings = new Tools_settings_class(); + this.width = width; + this.height = height; + this.layer_id = layer_id; + this.can_automate = can_automate; + this.ignore_same_size = ignore_same_size; + this.old_config_width = null; + this.old_config_height = null; + } + + async do() { + super.do(); + const width = this.width; + const height = this.height; + const can_automate = this.can_automate; + let need_fit = false; + let new_config_width = config.WIDTH; + let new_config_height = config.HEIGHT; + var enable_autoresize = this.Tools_settings.get_setting('enable_autoresize'); + + if(enable_autoresize == false){ + return; + } + + // Resize up + if (width > new_config_width || height > new_config_height) { + const wrapper = document.getElementById('main_wrapper'); + const page_w = wrapper.clientWidth; + const page_h = wrapper.clientHeight; + + if (width > page_w || height > page_h) { + need_fit = true; + } + if (width > new_config_width) + new_config_width = parseInt(width); + if (height > new_config_height) + new_config_height = parseInt(height); + } + + // Resize down + if (config.layers.length == 1 && can_automate !== false) { + if (width < new_config_width) + new_config_width = parseInt(width); + if (height < new_config_height) + new_config_height = parseInt(height); + } + + if (new_config_width !== config.WIDTH || new_config_height !== height) { + this.old_config_width = config.WIDTH; + this.old_config_height = config.HEIGHT; + config.WIDTH = new_config_width; + config.HEIGHT = new_config_height; + app.GUI.prepare_canvas(); + } else if (!this.ignore_same_size) { + throw new Error('Aborted - Resize not necessary') + } + + // Fit zoom when after short pause + // @todo - remove setTimeout + if (need_fit == true) { + await new Promise((resolve) => { + window.setTimeout(() => { + app.GUI.GUI_preview.zoom_auto(); + resolve(); + }, 100); + }); + } + } + + async undo() { + super.undo(); + if (this.old_config_width != null) { + config.WIDTH = this.old_config_width; + } + if (this.old_config_height != null) { + config.HEIGHT = this.old_config_height; + } + if (this.old_config_width != null || this.old_config_height != null) { + app.GUI.prepare_canvas(); + } + this.old_config_width = null; + this.old_config_height = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/base.js b/paintplus/frontend/src/js/actions/base.js new file mode 100644 index 0000000..9608dc7 --- /dev/null +++ b/paintplus/frontend/src/js/actions/base.js @@ -0,0 +1,19 @@ + +export class Base_action { + constructor(action_id, action_description) { + this.action_id = action_id; + this.action_description = action_description; + this.is_done = false; + this.memory_estimate = 0; // Estimate of how much memory will be freed when the free() method is called (in bytes) + this.database_estimate = 0; // Estimate of how much database space will be freed when the free() method is called (in bytes) + } + do() { + this.is_done = true; + } + undo() { + this.is_done = false; + } + free() { + // Override if need to run tasks to free memory when action is discarded from history + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/bundle.js b/paintplus/frontend/src/js/actions/bundle.js new file mode 100644 index 0000000..7c61b08 --- /dev/null +++ b/paintplus/frontend/src/js/actions/bundle.js @@ -0,0 +1,59 @@ +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Bundle_action extends Base_action { + /** + * Groups multiple actions together in the undo/redo history, runs them all at once. + */ + constructor(bundle_id, bundle_name, actions_to_do) { + super(bundle_id, bundle_name); + this.actions_to_do = actions_to_do; + } + + async do() { + super.do(); + let error = null; + let i = 0; + this.memory_estimate = 0; + this.database_estimate = 0; + for (i = 0; i < this.actions_to_do.length; i++) { + try { + await this.actions_to_do[i].do(); + this.memory_estimate += this.actions_to_do[i].memory_estimate; + this.database_estimate += this.actions_to_do[i].database_estimate; + } catch (e) { + error = e; + break; + } + } + // One of the actions aborted, undo all previous actions. + if (error) { + for (i--; i >= 0; i--) { + await this.actions_to_do[i].undo(); + } + throw error; + } + config.need_render = true; + } + + async undo() { + super.undo(); + this.memory_estimate = 0; + this.database_estimate = 0; + for (let i = this.actions_to_do.length - 1; i >= 0; i--) { + await this.actions_to_do[i].undo(); + this.memory_estimate += this.actions_to_do[i].memory_estimate; + this.database_estimate += this.actions_to_do[i].database_estimate; + } + config.need_render = true; + } + + free() { + if (this.actions_to_do) { + for (let action of this.actions_to_do) { + action.free(); + } + this.actions_to_do = null; + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/clear-layer.js b/paintplus/frontend/src/js/actions/clear-layer.js new file mode 100644 index 0000000..05da875 --- /dev/null +++ b/paintplus/frontend/src/js/actions/clear-layer.js @@ -0,0 +1,82 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Clear_layer_action extends Base_action { + /** + * clear layer data + * + * @param {int} layer_id + */ + constructor(layer_id) { + super('clear_layer', 'Clear Layer'); + this.layer_id = parseInt(layer_id); + this.update_layer_action = null; + this.delete_layer_settings_action = null; + } + + async do() { + super.do(); + let layer = app.Layers.get_layer(this.layer_id); + if (!layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + let new_settings = { + x: 0, + y: 0, + width: 0, + height: 0, + visible: true, + opacity: 100, + composition: null, + rotate: 0, + data: null, + params: {}, + status: null, + render_function: null, + type: null + }; + if (layer.type == 'image') { + //clean image + new_settings.link = null; + } + this.update_layer_action = new app.Actions.Update_layer_action(this.layer_id, new_settings); + await this.update_layer_action.do(); + let delete_setting_names = []; + for (let prop_name in layer) { + //remove private attributes + if (prop_name[0] == '_') { + delete_setting_names.push(prop_name); + } + } + if (delete_setting_names.length > 0) { + this.delete_layer_settings_action = new app.Actions.Delete_layer_settings_action(this.layer_id, delete_setting_names); + await this.delete_layer_settings_action.do(); + } + } + + async undo() { + super.undo(); + if (this.delete_layer_settings_action) { + await this.delete_layer_settings_action.undo(); + this.delete_layer_settings_action.free(); + this.delete_layer_settings_action = null; + } + if (this.update_layer_action) { + await this.update_layer_action.undo(); + this.update_layer_action.free(); + this.update_layer_action = null; + } + } + + free() { + if (this.update_layer_action) { + this.update_layer_action.free(); + this.update_layer_action = null; + } + if (this.delete_layer_settings_action) { + this.delete_layer_settings_action.free(); + this.delete_layer_settings_action = null; + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/delete-layer-filter.js b/paintplus/frontend/src/js/actions/delete-layer-filter.js new file mode 100644 index 0000000..c1250ea --- /dev/null +++ b/paintplus/frontend/src/js/actions/delete-layer-filter.js @@ -0,0 +1,60 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Delete_layer_filter_action extends Base_action { + /** + * delete live filter + * + * @param {int} layer_id + * @param {string} filter_id + */ + constructor(layer_id, filter_id) { + super('delete_layer_filter', 'Delete Layer Filter'); + if (layer_id == null) + layer_id = config.layer.id; + this.layer_id = parseInt(layer_id); + this.filter_id = filter_id; + this.reference_layer = null; + this.filter_remove_index = null; + this.old_filter = null; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + this.old_filter = null; + for (let i in this.reference_layer.filters) { + if (this.reference_layer.filters[i].id == this.filter_id) { + this.filter_remove_index = i; + this.old_filter = this.reference_layer.filters.splice(i, 1)[0]; + break; + } + } + if (!this.old_filter) { + throw new Error('Aborted - filter with specified id doesn\'t exist in layer'); + } + config.need_render = true; + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + if (this.reference_layer && this.old_filter) { + this.reference_layer.filters.splice(this.filter_remove_index, 0, this.old_filter); + } + this.reference_layer = null; + this.old_filter = null; + this.filter_remove_index = null; + config.need_render = true; + app.GUI.GUI_layers.render_layers(); + } + + free() { + this.reference_layer = null; + this.old_filter = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/delete-layer-settings.js b/paintplus/frontend/src/js/actions/delete-layer-settings.js new file mode 100644 index 0000000..4bceeef --- /dev/null +++ b/paintplus/frontend/src/js/actions/delete-layer-settings.js @@ -0,0 +1,50 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Delete_layer_settings_action extends Base_action { + /** + * Deletes the specified settings in a layer + * + * @param {int} layer_id + * @param {array} setting_names + */ + constructor(layer_id, setting_names) { + super('delete_layer_settings', 'Delete Layer Settings'); + this.layer_id = parseInt(layer_id); + this.setting_names = setting_names; + this.reference_layer = null; + this.old_settings = {}; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + for (let name in this.setting_names) { + this.old_settings[name] = this.reference_layer[name]; + delete this.reference_layer[name]; + } + config.need_render = true; + } + + async undo() { + super.undo(); + if (this.reference_layer) { + for (let i in this.old_settings) { + this.reference_layer[i] = this.old_settings[i]; + } + this.old_settings = {}; + } + this.reference_layer = null; + config.need_render = true; + } + + free() { + this.setting_names = null; + this.reference_layer = null; + this.old_settings = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/delete-layer.js b/paintplus/frontend/src/js/actions/delete-layer.js new file mode 100644 index 0000000..20fd6cd --- /dev/null +++ b/paintplus/frontend/src/js/actions/delete-layer.js @@ -0,0 +1,115 @@ +import config from '../config.js'; +import app from './../app.js'; +import { Base_action } from './base.js'; + +export class Delete_layer_action extends Base_action { + /** + * removes layer + * + * @param {int} id + * @param {boolean} force - Force to delete first layer? + */ + constructor(layer_id, force) { + super('delete_layer', 'Delete Layer'); + this.layer_id = parseInt(layer_id); + this.force = force || false; + this.insert_layer_action = null; + this.select_layer_action = null; + this.delete_index = null; + this.deleted_layer = null; + } + + async do() { + super.do(); + const id = this.layer_id; + const force = this.force; + + // Determine if there is a layer to delete, abort if not + for (var i in config.layers) { + if (config.layers[i].id == id) { + this.delete_index = i; + } + } + if (this.delete_index === null) { + throw new Error('Aborted - Layer to delete not found'); + } + + if (config.layers.length == 1 && (force == undefined || force == false)) { + // Only 1 layer left + if (config.layer.type == null) { + //STOP + throw new Error('Aborted - Will not delete last layer'); + } + else { + // Delete it, but before that - create new empty layer + this.insert_layer_action = new app.Actions.Insert_layer_action(); + this.insert_layer_action.do(); + } + } + + if (config.layers.length > 1 && config.layer.id == id) { + // Select next or previous layer + try { + const select_action = new app.Actions.Select_next_layer_action(id); + await select_action.do(); + this.select_layer_action = select_action; + } catch (error) { + const select_action = new app.Actions.Select_previous_layer_action(id); + await select_action.do(); + this.select_layer_action = select_action; + } + } + + // Remove layer from list + this.deleted_layer = config.layers.splice(this.delete_index, 1)[0]; + + // Estimate memory + if (this.deleted_layer.link && this.deleted_layer.link.src && typeof this.deleted_layer.link.src === 'string') { + this.memory_estimate = new Blob([this.deleted_layer.link.src]).size; + } + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + if (this.deleted_layer) { + config.layers.splice(this.delete_index, 0, this.deleted_layer); + this.delete_index = null; + this.deleted_layer = null; + } + if (this.select_layer_action) { + await this.select_layer_action.undo(); + this.select_layer_action.free(); + this.select_layer_action = null; + } + if (this.insert_layer_action) { + await this.insert_layer_action.undo(); + this.insert_layer_action.free(); + this.insert_layer_action = null; + } + + // Estimate memory + this.memory_estimate = 0; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + free() { + if (this.deleted_layer) { + delete this.deleted_layer.link; + delete this.deleted_layer.data; + } + if (this.insert_layer_action) { + this.insert_layer_action.free(); + this.insert_layer_action = null; + } + if (this.select_layer_action) { + this.select_layer_action.free(); + this.select_layer_action = null; + } + this.deleted_layer = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/index.js b/paintplus/frontend/src/js/actions/index.js new file mode 100644 index 0000000..2a33078 --- /dev/null +++ b/paintplus/frontend/src/js/actions/index.js @@ -0,0 +1,26 @@ +export { Activate_tool_action } from './activate-tool.js'; +export { Add_layer_filter_action } from './add-layer-filter.js'; +export { Autoresize_canvas_action } from './autoresize-canvas.js'; +export { Bundle_action } from './bundle.js'; +export { Clear_layer_action } from './clear-layer.js'; +export { Delete_layer_action } from './delete-layer.js'; +export { Delete_layer_filter_action } from './delete-layer-filter.js'; +export { Delete_layer_settings_action } from './delete-layer-settings.js'; +export { Init_canvas_zoom_action } from './init-canvas-zoom.js'; +export { Insert_layer_action } from './insert-layer.js'; +export { Prepare_canvas_action } from './prepare-canvas.js'; +export { Reorder_layer_action } from './reorder-layer.js'; +export { Reset_layers_action } from './reset-layers.js'; +export { Refresh_action_attributes_action } from './refresh-action-attributes.js'; +export { Refresh_layers_gui_action } from './refresh-layers-gui.js'; +export { Reset_selection_action } from './reset-selection.js'; +export { Select_layer_action } from './select-layer.js'; +export { Select_next_layer_action } from './select-next-layer.js'; +export { Select_previous_layer_action } from './select-previous-layer.js'; +export { Set_object_property_action } from './set-object-property.js'; +export { Set_selection_action } from './set-selection.js'; +export { Stop_animation_action } from './stop-animation.js'; +export { Toggle_layer_visibility_action } from './toggle-layer-visibility.js'; +export { Update_config_action } from './update-config.js'; +export { Update_layer_image_action } from './update-layer-image.js'; +export { Update_layer_action } from './update-layer.js'; \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/init-canvas-zoom.js b/paintplus/frontend/src/js/actions/init-canvas-zoom.js new file mode 100644 index 0000000..220b94b --- /dev/null +++ b/paintplus/frontend/src/js/actions/init-canvas-zoom.js @@ -0,0 +1,45 @@ +import app from '../app.js'; +import config from '../config.js'; +import zoomView from '../libs/zoomView.js'; +import { Base_action } from './base.js'; + +export class Init_canvas_zoom_action extends Base_action { + /** + * Resets the canvas + */ + constructor() { + super('init_canvas_zoom', 'Initialize Canvas Zoom'); + this.old_bounds = null; + this.old_context = null; + this.old_stable_dimensions = null; + } + + async do() { + super.do(); + this.old_bounds = zoomView.getBounds(); + this.old_context = zoomView.getContext(); + this.old_stable_dimensions = app.Layers.stable_dimensions; + zoomView.setBounds(0, 0, config.WIDTH, config.HEIGHT); + zoomView.setContext(app.Layers.ctx); + app.Layers.stable_dimensions = [ + config.WIDTH, + config.HEIGHT + ]; + } + + async undo() { + super.undo(); + zoomView.setBounds(this.old_bounds.top, this.old_bounds.left, this.old_bounds.right, this.old_bounds.bottom); + zoomView.setContext(this.old_context); + app.Layers.stable_dimensions = this.old_stable_dimensions; + this.old_bounds = null; + this.old_context = null; + this.old_stable_dimensions = null; + } + + free() { + this.old_bounds = null; + this.old_context = null; + this.old_stable_dimensions = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/insert-layer.js b/paintplus/frontend/src/js/actions/insert-layer.js new file mode 100644 index 0000000..16c2306 --- /dev/null +++ b/paintplus/frontend/src/js/actions/insert-layer.js @@ -0,0 +1,214 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +export class Insert_layer_action extends Base_action { + /** + * Creates new layer + * + * @param {object} settings + * @param {boolean} can_automate + */ + constructor(settings, can_automate = true) { + super('insert_layer', 'Insert Layer'); + this.settings = settings; + this.can_automate = can_automate; + this.previous_auto_increment = null; + this.previous_selected_layer = null; + this.inserted_layer_id = null; + this.update_layer_action = null; + this.delete_layer_action = null; + this.autoresize_canvas_action = null; + } + + async do() { + super.do(); + + this.previous_auto_increment = app.Layers.auto_increment; + this.previous_selected_layer = config.layer; + let autoresize_as = null; + + // Default data + const layer = { + id: app.Layers.auto_increment, + parent_id: 0, + name: config.TOOL.name.charAt(0).toUpperCase() + config.TOOL.name.slice(1) + ' #' + app.Layers.auto_increment, + type: null, + link: null, + x: 0, + y: 0, + width: null, + width_original: null, + height: null, + height_original: null, + visible: true, + is_vector: false, + hide_selection_if_active: false, + opacity: 100, + order: app.Layers.auto_increment, + composition: 'source-over', + rotate: 0, + data: null, + params: {}, + status: null, + color: config.COLOR, + filters: [], + render_function: null, + }; + + // Build data + for (let i in this.settings) { + if (typeof layer[i] == "undefined" && !i.startsWith('_')) { + alertify.error('Error: wrong key: ' + i); + continue; + } + layer[i] = this.settings[i]; + } + + // Prepare image + let image_load_promise; + if (layer.type == 'image') { + + if(layer.name.toLowerCase().indexOf('.svg') == layer.name.length - 4){ + // We have svg + layer.is_vector = true; + } + + if (config.layers.length == 1 && (config.layer.width == 0 || config.layer.width === null) + && (config.layer.height == 0 || config.layer.height === null) && config.layer.data == null) { + // Remove first empty layer + + this.delete_layer_action = new app.Actions.Delete_layer_action(config.layer.id, true); + await this.delete_layer_action.do(); + } + + if (layer.link == null) { + if (typeof layer.data == 'object') { + // Load actual image + if (layer.width == 0 || layer.width === null) + layer.width = layer.data.width; + if (layer.height == 0 || layer.height === null) + layer.height = layer.data.height; + layer.link = layer.data.cloneNode(true); + layer.link.onload = function () { + config.need_render = true; + }; + layer.data = null; + autoresize_as = [layer.width, layer.height, null, true, true]; + //need_autoresize = true; + } + else if (typeof layer.data == 'string') { + image_load_promise = new Promise((resolve, reject) => { + // Try loading as imageData + layer.link = new Image(); + layer.link.onload = () => { + // Update dimensions + if (layer.width == 0 || layer.width === null) + layer.width = layer.link.width; + if (layer.height == 0 || layer.height === null) + layer.height = layer.link.height; + if (layer.width_original == null) + layer.width_original = layer.width; + if (layer.height_original == null) + layer.height_original = layer.height; + // Free data + layer.data = null; + autoresize_as = [layer.width, layer.height, layer.id, this.can_automate, true]; + config.need_render = true; + resolve(); + }; + layer.link.onerror = (error) => { + resolve(error); + alertify.error('Sorry, image could not be loaded.'); + }; + layer.link.src = layer.data; + layer.link.crossOrigin = "Anonymous"; + }); + } + else { + alertify.error('Error: can not load image.'); + } + } + } + + if (this.settings != undefined && config.layers.length > 0 + && (config.layer.width == 0 || config.layer.width === null) && (config.layer.height == 0 || config.layer.height === null) + && config.layer.data == null && layer.type != 'image' && this.can_automate !== false) { + // Update existing layer, because it's empty + this.update_layer_action = new app.Actions.Update_layer_action(config.layer.id, layer); + await this.update_layer_action.do(); + } + else { + // Create new layer + config.layers.push(layer); + config.layer = app.Layers.get_layer(layer.id); + app.Layers.auto_increment++; + + if (config.layer == null) { + config.layer = config.layers[0]; + } + + this.inserted_layer_id = layer.id; + } + + if (layer.id >= app.Layers.auto_increment) + app.Layers.auto_increment = layer.id + 1; + + if (image_load_promise) { + await image_load_promise; + } + + if (autoresize_as) { + this.autoresize_canvas_action = new app.Actions.Autoresize_canvas_action(...autoresize_as); + try { + await this.autoresize_canvas_action.do(); + } catch(error) { + this.autoresize_canvas_action = null; + } + } + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + app.Layers.auto_increment = this.previous_auto_increment; + if (this.autoresize_canvas_action) { + await this.autoresize_canvas_action.undo(); + this.autoresize_canvas_action = null; + } + if (this.inserted_layer_id) { + config.layers.pop(); + this.inserted_layer_id = null; + } + if (this.update_layer_action) { + await this.update_layer_action.undo(); + this.update_layer_action.free(); + this.update_layer_action = null; + } + if (this.delete_layer_action) { + await this.delete_layer_action.undo(); + this.delete_layer_action.free(); + this.delete_layer_action = null; + } + config.layer = this.previous_selected_layer; + this.previous_selected_layer = null; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + free() { + if (this.delete_layer_action) { + this.delete_layer_action.free(); + this.delete_layer_action = null; + } + if (this.update_layer_action) { + this.update_layer_action.free(); + this.update_layer_action = null; + } + this.previous_selected_layer = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/prepare-canvas.js b/paintplus/frontend/src/js/actions/prepare-canvas.js new file mode 100644 index 0000000..ab95f15 --- /dev/null +++ b/paintplus/frontend/src/js/actions/prepare-canvas.js @@ -0,0 +1,29 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Prepare_canvas_action extends Base_action { + /** + * Resizes/renders the canvas at the specified step. Usually used on both sides of a config update action. + * + * @param {boolean} call_when + */ + constructor(call_when = 'undo') { + super('prepare_canvas', 'Prepare Canvas'); + this.call_when = call_when; + } + + async do() { + super.do(); + if (this.call_when === 'do') { + app.GUI.prepare_canvas(); + } + } + + async undo() { + super.undo(); + if (this.call_when === 'undo') { + app.GUI.prepare_canvas(); + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/refresh-action-attributes.js b/paintplus/frontend/src/js/actions/refresh-action-attributes.js new file mode 100644 index 0000000..8ec21a1 --- /dev/null +++ b/paintplus/frontend/src/js/actions/refresh-action-attributes.js @@ -0,0 +1,29 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Refresh_action_attributes_action extends Base_action { + /** + * Resizes/renders the canvas at the specified step. Usually used on both sides of a config update action. + * + * @param {boolean} call_when + */ + constructor(call_when = 'undo') { + super('refresh_action_attributes', 'Refresh Action Attributes'); + this.call_when = call_when; + } + + async do() { + super.do(); + if (this.call_when === 'do') { + app.GUI.GUI_tools.show_action_attributes(); + } + } + + async undo() { + super.undo(); + if (this.call_when === 'undo') { + app.GUI.GUI_tools.show_action_attributes(); + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/refresh-layers-gui.js b/paintplus/frontend/src/js/actions/refresh-layers-gui.js new file mode 100644 index 0000000..3f11098 --- /dev/null +++ b/paintplus/frontend/src/js/actions/refresh-layers-gui.js @@ -0,0 +1,29 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Refresh_layers_gui_action extends Base_action { + /** + * Resizes/renders the canvas at the specified step. Usually used on both sides of a config update action. + * + * @param {boolean} call_when + */ + constructor(call_when = 'undo') { + super('refresh_gui', 'Refresh GUI'); + this.call_when = call_when; + } + + async do() { + super.do(); + if (this.call_when === 'do') { + app.Layers.refresh_gui(); + } + } + + async undo() { + super.undo(); + if (this.call_when === 'undo') { + app.Layers.refresh_gui(); + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/reorder-layer.js b/paintplus/frontend/src/js/actions/reorder-layer.js new file mode 100644 index 0000000..45c2c95 --- /dev/null +++ b/paintplus/frontend/src/js/actions/reorder-layer.js @@ -0,0 +1,64 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Reorder_layer_action extends Base_action { + /** + * Reorder layer up or down in the layer stack + * + * @param {int} layer_id + * @param {int} direction + */ + constructor(layer_id, direction) { + super('reorder_layer', 'Reorder Layer'); + this.layer_id = parseInt(layer_id); + this.direction = direction; + this.reference_layer = null; + this.reference_target = null; + this.old_layer_order = null; + this.old_target_order = null; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + if (this.direction < 0) { + this.reference_target = app.Layers.find_previous(this.layer_id); + } + else { + this.reference_target = app.Layers.find_next(this.layer_id); + } + if (!this.reference_target) { + throw new Error('Aborted - layer has nowhere to move'); + } + this.old_layer_order = this.reference_layer.order; + this.old_target_order = this.reference_target.order; + this.reference_layer.order = this.old_target_order; + this.reference_target.order = this.old_layer_order; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + if (this.reference_layer) { + this.reference_layer.order = this.old_layer_order; + this.reference_layer = null; + } + if (this.reference_target) { + this.reference_target.order = this.old_target_order; + this.reference_target = null; + } + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + free() { + this.reference_layer = null; + this.reference_target = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/reset-layers.js b/paintplus/frontend/src/js/actions/reset-layers.js new file mode 100644 index 0000000..1620043 --- /dev/null +++ b/paintplus/frontend/src/js/actions/reset-layers.js @@ -0,0 +1,66 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Reset_layers_action extends Base_action { + /* + * removes all layers + */ + constructor(auto_insert) { + super('reset_layers', 'Reset Layers'); + this.auto_insert = auto_insert; + this.previous_auto_increment = null; + this.delete_actions = null; + this.insert_action = null; + } + async do() { + super.do(); + const auto_insert = this.auto_insert; + this.previous_auto_increment = app.Layers.auto_increment; + + this.delete_actions = []; + for (let i = config.layers.length - 1; i >= 0; i--) { + const delete_action = new app.Actions.Delete_layer_action(config.layers[i].id, true); + await delete_action.do(); + this.delete_actions.push(delete_action); + } + app.Layers.auto_increment = 1; + + if (auto_insert != undefined && auto_insert === true) { + const settings = {}; + this.insert_action = new app.Actions.Insert_layer_action(settings); + await this.insert_action.do(); + } + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + async undo() { + super.undo(); + if (this.insert_action) { + await this.insert_action.undo(); + this.insert_action.free(); + this.insert_action = null; + } + for (let i = this.delete_actions.length - 1; i >= 0; i--) { + await this.delete_actions[i].undo(); + this.delete_actions[i].free(); + } + app.Layers.auto_increment = this.previous_auto_increment; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + free() { + if (this.insert_action) { + this.insert_action.free(); + this.insert_action = null; + } + if (this.delete_actions) { + for (let action of this.delete_actions) { + action.free(); + } + this.delete_actions = null; + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/reset-selection.js b/paintplus/frontend/src/js/actions/reset-selection.js new file mode 100644 index 0000000..d258a05 --- /dev/null +++ b/paintplus/frontend/src/js/actions/reset-selection.js @@ -0,0 +1,57 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Reset_selection_action extends Base_action { + /** + * Sets the selection to empty + * + * @prop {object} [mirror_selection_settings] - Optional object to also set to an empty selection object + */ + constructor(mirror_selection_settings) { + super('reset_selection', 'Reset Selection'); + this.mirror_selection_settings = mirror_selection_settings; + this.settings_reference = null; + this.old_settings_data = null; + } + + async do() { + super.do(); + this.settings_reference = app.Layers.Base_selection.find_settings(); + this.old_settings_data = JSON.parse(JSON.stringify(this.settings_reference.data)); + this.settings_reference.data = { + x: null, + y: null, + width: null, + height: null + } + if (this.mirror_selection_settings) { + this.mirror_selection_settings.x = null; + this.mirror_selection_settings.y = null; + this.mirror_selection_settings.width = null; + this.mirror_selection_settings.height = null; + } + config.need_render = true; + } + + async undo() { + super.undo(); + if (this.old_settings_data) { + for (let prop of ['x', 'y', 'width', 'height']) { + this.settings_reference.data[prop] = this.old_settings_data[prop]; + if (this.mirror_selection_settings) { + this.mirror_selection_settings[prop] = this.old_settings_data[prop]; + } + } + } + this.settings_reference = null; + this.old_settings_data = null; + config.need_render = true; + } + + free() { + this.settings_reference = null; + this.old_settings_data = null; + this.mirror_selection_settings = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/select-layer.js b/paintplus/frontend/src/js/actions/select-layer.js new file mode 100644 index 0000000..f1252be --- /dev/null +++ b/paintplus/frontend/src/js/actions/select-layer.js @@ -0,0 +1,57 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Select_layer_action extends Base_action { + /** + * marks layer as selected, active + * + * @param {int} layer_id + */ + constructor(layer_id, ignore_same_selection = false) { + super('select_layer', 'Select Layer'); + this.reset_selection_action = null; + this.layer_id = parseInt(layer_id); + this.ignore_same_selection = ignore_same_selection; + this.old_layer = null; + } + + async do() { + super.do(); + + let old_layer = config.layer; + let new_layer = app.Layers.get_layer(this.layer_id); + + if (old_layer !== new_layer) { + this.old_layer = old_layer; + config.layer = new_layer; + } else if (!this.ignore_same_selection) { + throw new Error('Aborted - Layer already selected'); + } + + this.reset_selection_action = new app.Actions.Reset_selection_action(); + await this.reset_selection_action.do(); + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + + if (this.reset_selection_action) { + await this.reset_selection_action.undo(); + this.reset_selection_action = null; + } + + config.layer = this.old_layer; + this.old_layer = null; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + free() { + this.old_layer = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/select-next-layer.js b/paintplus/frontend/src/js/actions/select-next-layer.js new file mode 100644 index 0000000..d16ecc6 --- /dev/null +++ b/paintplus/frontend/src/js/actions/select-next-layer.js @@ -0,0 +1,33 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Select_next_layer_action extends Base_action { + constructor(reference_layer_id) { + super('select_next_layer', 'Select Next Layer'); + this.reference_layer_id = reference_layer_id; + this.old_config_layer = null; + } + + async do() { + super.do(); + const next_layer = app.Layers.find_next(this.reference_layer_id); + if (!next_layer) { + throw new Error('Aborted - Next layer to select not found'); + } + this.old_config_layer = config.layer; + config.layer = next_layer; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + config.layer = this.old_config_layer; + this.old_config_layer = null; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/select-previous-layer.js b/paintplus/frontend/src/js/actions/select-previous-layer.js new file mode 100644 index 0000000..819edbc --- /dev/null +++ b/paintplus/frontend/src/js/actions/select-previous-layer.js @@ -0,0 +1,33 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Select_previous_layer_action extends Base_action { + constructor(reference_layer_id) { + super('select_previous_layer', 'Select Previous Layer'); + this.reference_layer_id = reference_layer_id; + this.old_config_layer = null; + } + + async do() { + super.do(); + const previous_layer = app.Layers.find_previous(this.reference_layer_id); + if (!previous_layer) { + throw new Error('Aborted - Previous layer to select not found'); + } + this.old_config_layer = config.layer; + config.layer = previous_layer; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + config.layer = this.old_config_layer; + this.old_config_layer = null; + + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/set-object-property.js b/paintplus/frontend/src/js/actions/set-object-property.js new file mode 100644 index 0000000..558bbb8 --- /dev/null +++ b/paintplus/frontend/src/js/actions/set-object-property.js @@ -0,0 +1,35 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Set_object_property_action extends Base_action { + /** + * Sets a generic object property. I recommend against using this as it's generally a hack for edge cases. + * + * @param {string} layer_id + * @param {object} settings + */ + constructor(object, property_name, value) { + super('set_object_property', 'Set Object Property'); + this.object = object; + this.property_name = property_name; + this.value = value; + this.old_value = null; + } + + async do() { + super.do(); + this.old_value = this.object[this.property_name]; + this.object[this.property_name] = this.value; + } + + async undo() { + super.undo(); + this.object[this.property_name] = this.old_value; + this.old_value = null; + } + + free() { + this.object = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/set-selection.js b/paintplus/frontend/src/js/actions/set-selection.js new file mode 100644 index 0000000..8aacf0c --- /dev/null +++ b/paintplus/frontend/src/js/actions/set-selection.js @@ -0,0 +1,57 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Set_selection_action extends Base_action { + /** + * Sets the selection to the specified position and dimensions + */ + constructor(x, y, width, height, old_settings_override) { + super('set_selection', 'Set Selection'); + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.settings_reference = null; + this.old_settings_data = null; + this.old_settings_override = old_settings_override ? JSON.parse(JSON.stringify(old_settings_override)) || null : null; + } + + async do() { + super.do(); + this.settings_reference = app.Layers.Base_selection.find_settings(); + this.old_settings_data = JSON.parse(JSON.stringify(this.settings_reference.data)); + if (this.x != null) + this.settings_reference.data.x = this.x; + if (this.y != null) + this.settings_reference.data.y = this.y; + if (this.width != null) + this.settings_reference.data.width = this.width; + if (this.height != null) + this.settings_reference.data.height = this.height; + + config.need_render = true; + } + + async undo() { + super.undo() + if (this.old_settings_override) { + for (let prop in this.old_settings_override) { + this.settings_reference.data[prop] = this.old_settings_override[prop]; + } + } else { + for (let prop in this.old_settings_data) { + this.settings_reference.data[prop] = this.old_settings_data[prop]; + } + } + this.settings_reference = null; + this.old_settings_data = null; + config.need_render = true; + } + + free() { + this.settings_reference = null; + this.old_settings_override = null; + this.old_settings_data = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/stop-animation.js b/paintplus/frontend/src/js/actions/stop-animation.js new file mode 100644 index 0000000..7dcda6c --- /dev/null +++ b/paintplus/frontend/src/js/actions/stop-animation.js @@ -0,0 +1,59 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Stop_animation_action extends Base_action { + /** + * Stops the currently playing animation, both do and undo states will stop animation + */ + constructor(reset_layer_visibility) { + super('stop_animation', 'Stop Animation'); + this.reset_layer_visibility = !!reset_layer_visibility; + } + + async do() { + super.do(); + const animation_tool = app.GUI.GUI_tools.tools_modules.animation.object; + var params = animation_tool.getParams(); + if (animation_tool.intervalID == null) + return; + + clearInterval(animation_tool.intervalID); + params.play = false; + animation_tool.index = 0; + animation_tool.GUI_tools.show_action_attributes(); + + // make all visible + if (this.reset_layer_visibility) { + for (let i in config.layers) { + config.layers[i].visible = true; + } + } + + animation_tool.Base_gui.GUI_layers.render_layers(); + config.need_render = true; + } + + async undo() { + super.undo(); + const animation_tool = app.GUI.GUI_tools.tools_modules.animation.object; + var params = animation_tool.getParams(); + if (animation_tool.intervalID == null) + return; + + clearInterval(animation_tool.intervalID); + params.play = false; + animation_tool.index = 0; + animation_tool.GUI_tools.show_action_attributes(); + + // make all visible + if (this.reset_layer_visibility) { + for (let i in config.layers) { + config.layers[i].visible = true; + } + } + + animation_tool.Base_gui.GUI_layers.render_layers(); + config.need_render = true; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/store/image-store.js b/paintplus/frontend/src/js/actions/store/image-store.js new file mode 100644 index 0000000..4627c6c --- /dev/null +++ b/paintplus/frontend/src/js/actions/store/image-store.js @@ -0,0 +1,222 @@ +import { v4 as uuidv4 } from 'uuid'; + +// Get a unique id to identify this tab's history in the database +let tabUuid; +try { + tabUuid = sessionStorage.getItem('history_tab_uuid'); +} catch (error) {} +if (!tabUuid) { + tabUuid = uuidv4(); + try { + sessionStorage.setItem('history_tab_uuid', tabUuid); + } catch (error) {} +} + +let imageIdCounter = 0; +let database = null; +let databaseInitPromise = null; +const tabPingInterval = 60000; +const assumeTabIsClosedTimeout = 300000; // Inactive tabs setInterval is slowed down in most browsers, this should be significantly higher than tabPingInterval + +export default { + /** + * Initializes the database + */ + async init() { + if (!databaseInitPromise) { + databaseInitPromise = new Promise(async (resolveInit) => { + try { + if (window.indexedDB) { + // Delete database from a previous page load, if no other tabs have notified that they're open in a while + let shouldDeleteDatabase = true; + try { + let lastDatabaseTabPing = localStorage.getItem('history_usage_ping'); + shouldDeleteDatabase = (!lastDatabaseTabPing || parseInt(lastDatabaseTabPing, 10) < new Date().getTime() - assumeTabIsClosedTimeout); + } catch (error) {} + if (shouldDeleteDatabase) { + await new Promise((resolve, reject) => { + let deleteRequest = window.indexedDB.deleteDatabase('undoHistoryImageStore'); + deleteRequest.onerror = () => { + reject(deleteRequest.error); + }; + deleteRequest.onsuccess = () => { + resolve(); + }; + }); + } + // Initialize database + await new Promise((resolve, reject) => { + let openRequest = window.indexedDB.open('undoHistoryImageStore', 1); + openRequest.onupgradeneeded = function(event) { + database = openRequest.result; + switch (event.oldVersion) { + case 0: + database.createObjectStore('images', { keyPath: 'id' }); + break; + } + }; + openRequest.onerror = () => { + reject(openRequest.error); + } + openRequest.onsuccess = () => { + resolve(); + database = openRequest.result; + } + }); + if (!database) { + throw new Error('indexedDB not initialized'); + } + // Delete history from previous session + try { + await this.delete_all(); + } catch (error) {} + // Ping localStorage for as long as this browser tab is open + localStorage.setItem('history_usage_ping', new Date().getTime() + ''); + setInterval(() => { + localStorage.setItem('history_usage_ping', new Date().getTime() + ''); + }, tabPingInterval); + } + } catch (error) { + database = { + isMemory: true, + images: {} + }; + } + resolveInit(); + }); + await databaseInitPromise; + } else if (!database) { + await databaseInitPromise; + } + }, + + /** + * Adds the specified image to the database. Returns a promise that is resolved with an id that can be used to retrieve it again. + * + * @param {string | canvas | ImageData} imageData the image data to store + * @returns {Promise} resolves with retrieval id + */ + async add(imageData) { + await this.init(); + let imageId = tabUuid + '-' + (imageIdCounter++); + if (database.isMemory) { + database.images[imageId] = imageData; + } else { + await new Promise((resolve, reject) => { + const transaction = database.transaction('images', 'readwrite'); + const images = transaction.objectStore('images'); + const image = { + id: imageId, + tabUuid, + data: imageData + } + const request = images.add(image); + request.onsuccess = function() { + resolve(); + }; + request.onerror = function() { + reject(request.error); + }; + }); + } + return imageId; + }, + + /** + * Gets the specified image from the database, by imageId retrieved from "add()" method. + * + * @param {string} imageId the id of the image to get + * @returns {Promise} resolves with the image + */ + async get(imageId) { + await this.init(); + if (database.isMemory) { + return database.images[imageId]; + } else { + return new Promise((resolve, reject) => { + const transaction = database.transaction('images', 'readonly'); + const images = transaction.objectStore('images'); + const request = images.get(imageId); + request.onsuccess = function() { + resolve(request.result && request.result.data); + }; + request.onerror = function() { + reject(request.error); + }; + }); + } + }, + + /** + * Deletes the specified image from the database, by imageId retrieved from "add()" method. + * + * @param {string} imageId the id of the image to delete + * @returns {Promise} + */ + async delete(imageId) { + await this.init(); + if (database.isMemory) { + delete database.images[imageId]; + } else { + return new Promise((resolve, reject) => { + const transaction = database.transaction('images', 'readwrite'); + const images = transaction.objectStore('images'); + const request = images.delete(imageId); + request.onsuccess = function() { + resolve(); + }; + request.onerror = function() { + reject(request.error); + }; + }); + } + }, + + /** + * Deletes all images associated with the current tab. + * + * @returns {Promise} + */ + async delete_all() { + await this.init(); + if (database.isMemory) { + database.images = {}; + } else { + return new Promise((resolve, reject) => { + const transaction = database.transaction('images', 'readwrite'); + const images = transaction.objectStore('images'); + const getAllImagesRequest = images.getAll(); + getAllImagesRequest.onsuccess = async function () { + const allImages = getAllImagesRequest.result; + let errorOccurred = false; + for (let image of allImages) { + if (image.tabUuid === tabUuid) { + try { + await new Promise((deleteResolve, deleteReject) => { + const request = images.delete(image.id); + request.onsuccess = function() { + deleteResolve(); + }; + request.onerror = function() { + deleteReject(request.error); + }; + }); + } catch (error) { + errorOccurred = true; + // Should eventually be deleted when database is deleted due to timeout + } + } + } + if (errorOccurred) { + // Use a different uuid to prevent conflicts + tabUuid = uuidv4(); + } + resolve(); + }; + getAllImagesRequest.onerror = function () { + reject(request.error); + }; + }); + } + } +}; \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/toggle-layer-visibility.js b/paintplus/frontend/src/js/actions/toggle-layer-visibility.js new file mode 100644 index 0000000..fa753c3 --- /dev/null +++ b/paintplus/frontend/src/js/actions/toggle-layer-visibility.js @@ -0,0 +1,37 @@ +import app from '../app.js'; +import config from '../config.js'; +import { Base_action } from './base.js'; + +export class Toggle_layer_visibility_action extends Base_action { + /** + * toggle layer visibility + * + * @param {int} layer_id + */ + constructor(layer_id) { + super('toggle_layer_visibility', 'Toggle Layer Visibility'); + this.layer_id = parseInt(layer_id); + this.old_visible = null; + } + + async do() { + super.do(); + const layer = app.Layers.get_layer(this.layer_id); + this.old_visible = layer.visible; + if (layer.visible == false) + layer.visible = true; + else + layer.visible = false; + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } + + async undo() { + super.undo(); + const layer = app.Layers.get_layer(this.layer_id); + layer.visible = this.old_visible; + this.old_visible = null; + app.Layers.render(); + app.GUI.GUI_layers.render_layers(); + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/update-config.js b/paintplus/frontend/src/js/actions/update-config.js new file mode 100644 index 0000000..d0c0409 --- /dev/null +++ b/paintplus/frontend/src/js/actions/update-config.js @@ -0,0 +1,37 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Update_config_action extends Base_action { + /** + * Updates the app config with the provided settings + * + * @param {object} settings + */ + constructor(settings) { + super('update_config', 'Update Config'); + this.settings = settings; + this.old_settings = {}; + } + + async do() { + super.do(); + for (let i in this.settings) { + this.old_settings[i] = config[i]; + config[i] = this.settings[i]; + } + } + + async undo() { + super.undo(); + for (let i in this.old_settings) { + config[i] = this.old_settings[i]; + } + this.old_settings = {}; + } + + free() { + this.settings = null; + this.old_settings = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/update-layer-image.js b/paintplus/frontend/src/js/actions/update-layer-image.js new file mode 100644 index 0000000..0941417 --- /dev/null +++ b/paintplus/frontend/src/js/actions/update-layer-image.js @@ -0,0 +1,149 @@ +import app from './../app.js'; +import config from './../config.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import image_store from './store/image-store.js'; +import { Base_action } from './base.js'; + +const Helper = new Helper_class(); + +export class Update_layer_image_action extends Base_action { + /** + * updates layer image data + * + * @param {canvas} canvas + * @param {int} layer_id (optional) + */ + constructor(canvas, layer_id) { + super('update_layer_image', 'Update Layer Image'); + this.canvas = canvas; + if (layer_id == null) + layer_id = config.layer.id; + this.layer_id = parseInt(layer_id); + this.reference_layer = null; + this.old_image_id = null; + this.new_image_id = null; + this.old_link_database_id = null; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + if (this.reference_layer.type != 'image'){ + alertify.error('Error: layer must be image.'); + throw new Error('Aborted - layer is not an image'); + } + + // Get data url representation of image + let canvas_data_url; + if (this.new_image_id) { + try { + canvas_data_url = await image_store.get(this.new_image_id); + } catch (error) { + throw new Error('Aborted - problem retrieving cached image from database'); + } + } else if (this.canvas) { + if (Helper.is_edge_or_ie() == false && typeof(FileReader) !== 'undefined') { + // Update image using blob and FileReader (async) + await new Promise((resolve) => { + this.canvas.toBlob((blob) => { + var reader = new FileReader(); + reader.onloadend = () => { + canvas_data_url = reader.result; + resolve(); + } + reader.readAsDataURL(blob); + }, 'image/png'); + }); + } + else { + // Slow way for IE, Edge + canvas_data_url = this.canvas.toDataURL(); + } + } + + // Store data url in database + try { + if (!this.old_image_id) { + if (this.reference_layer._link_database_id) { + this.old_image_id = this.reference_layer._link_database_id; + } else { + this.old_image_id = await image_store.add(this.reference_layer.link.src); + } + } + if (!this.new_image_id) { + this.new_image_id = await image_store.add(canvas_data_url); + } + } catch (error) { + console.log(error); + requestAnimationFrame(() => { + app.State.free(0, this.database_estimate || 1) + }); + } + + // Estimate storage size + try { + this.database_estimate = new Blob([await image_store.get(this.old_image_id)]).size; + } catch (e) {} + + // Assign layer properties + this.reference_layer.link.src = canvas_data_url; + this.old_link_database_id = this.reference_layer._link_database_id; + this.reference_layer._link_database_id = this.new_image_id; + + this.canvas = null; + config.need_render = true; + } + + async undo() { + super.undo(); + + // Estimate storage size + try { + this.database_estimate = new Blob([this.reference_layer.link.src]).size; + } catch (e) {} + + // Restore old image + if (this.old_image_id != null) { + try { + this.reference_layer.link.src = await image_store.get(this.old_image_id); + } catch (error) { + throw new Error('Failed to retrieve image from store'); + } + } + this.reference_layer._link_database_id = this.old_link_database_id; + this.reference_layer = null; + config.need_render = true; + } + + async free() { + let has_error = false; + if (this.new_image_id != null) { + try { + await image_store.delete(this.new_image_id); + } catch (error) { + has_error = true; + } + this.new_image_id = null; + } + if (this.is_done || !this.old_link_database_id) { + if (this.old_image_id != null) { + try { + await image_store.delete(this.old_image_id); + } catch (error) { + has_error = true; + } + this.old_image_id = null; + } + } + this.canvas = null; + this.old_link_database_id = null; + this.reference_layer = null; + if (has_error) { + alertify.error('A problem occurred while removing undo history. It\'s suggested you save your work and refresh the page in order to free up memory.'); + } + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/actions/update-layer.js b/paintplus/frontend/src/js/actions/update-layer.js new file mode 100644 index 0000000..83166d3 --- /dev/null +++ b/paintplus/frontend/src/js/actions/update-layer.js @@ -0,0 +1,67 @@ +import app from './../app.js'; +import config from './../config.js'; +import { Base_action } from './base.js'; + +export class Update_layer_action extends Base_action { + /** + * Updates an existing layer with the provided settings + * WARNING: If passing objects or arrays into settings, make sure these are new or cloned objects, and not a modified existing object! + * + * @param {string} layer_id + * @param {object} settings + */ + constructor(layer_id, settings) { + super('update_layer', 'Update Layer'); + this.layer_id = layer_id; + this.settings = settings; + this.reference_layer = null; + this.old_settings = {}; + } + + async do() { + super.do(); + this.reference_layer = app.Layers.get_layer(this.layer_id); + if (!this.reference_layer) { + throw new Error('Aborted - layer with specified id doesn\'t exist'); + } + for (let i in this.settings) { + if (i == 'id') + continue; + if (i == 'order') + continue; + this.old_settings[i] = this.reference_layer[i]; + this.reference_layer[i] = this.settings[i]; + } + if (this.reference_layer.type === 'text') { + this.reference_layer._needs_update_data = true; + } + if (this.settings.params || this.settings.width || this.settings.height) { + config.need_render_changed_params = true; + } + config.need_render = true; + } + + async undo() { + super.undo(); + if (this.reference_layer) { + for (let i in this.old_settings) { + this.reference_layer[i] = this.old_settings[i]; + } + if (this.reference_layer.type === 'text') { + this.reference_layer._needs_update_data = true; + } + if (this.old_settings.params || this.old_settings.width || this.old_settings.height) { + config.need_render_changed_params = true; + } + this.old_settings = {}; + } + this.reference_layer = null; + config.need_render = true; + } + + free() { + this.settings = null; + this.old_settings = null; + this.reference_layer = null; + } +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/api/capabilities.js b/paintplus/frontend/src/js/api/capabilities.js new file mode 100644 index 0000000..1944e3e --- /dev/null +++ b/paintplus/frontend/src/js/api/capabilities.js @@ -0,0 +1,84 @@ +/** + * Backend capabilities singleton. + * Fetched once on load from GET /api/config. + * Tools use this to decide whether to show, grey out, or show tooltips. + * + * Shape: + * { + * local: { lama, rembg, opencv, gpu_detected }, + * remote: { provider, capabilities: string[], healthy } + * } + */ + +import apiService from '../services/api.js'; + +const DEFAULT_CAPS = { + local: { lama: false, rembg: false, opencv: true, gpu_detected: false }, + remote: { provider: null, capabilities: [], healthy: false }, +}; + +let _caps = null; +let _fetchPromise = null; +let _gpuStatus = null; +let _gpuFetchPromise = null; + +/** + * Return capabilities (fetched lazily, cached thereafter). + * Always resolves — falls back to DEFAULT_CAPS on network error. + */ +export async function getCapabilities() { + if (_caps) return _caps; + if (!_fetchPromise) { + _fetchPromise = apiService.getConfig() + .then(data => { _caps = data || DEFAULT_CAPS; return _caps; }) + .catch(() => { _caps = DEFAULT_CAPS; return _caps; }); + } + return _fetchPromise; +} + +/** + * Synchronous check — returns cached value or DEFAULT_CAPS if not yet loaded. + */ +export function getCachedCapabilities() { + return _caps || DEFAULT_CAPS; +} + +/** + * True if the remote provider is configured and healthy. + */ +export function hasRemote() { + return !!(_caps?.remote?.healthy); +} + +/** + * Fetch and cache detailed GPU status (hardware, feature flags, model selection per op). + * Calls /api/gpu/status — only meaningful when AI_PROVIDER=local_gpu. + * Returns null on error. + */ +export async function getGpuStatus() { + if (_gpuStatus !== null) return _gpuStatus; + if (!_gpuFetchPromise) { + _gpuFetchPromise = apiService.getGpuStatus() + .then(data => { _gpuStatus = data; return _gpuStatus; }) + .catch(() => { _gpuStatus = null; return null; }); + } + return _gpuFetchPromise; +} + +/** + * Invalidate cache and re-fetch (call after saving provider settings). + */ +export async function refreshCapabilities() { + _caps = null; + _fetchPromise = null; + _gpuStatus = null; + _gpuFetchPromise = null; + return getCapabilities(); +} + +/** + * Kick off the fetch immediately at module load time so it's ready when tools need it. + */ +getCapabilities(); + +export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities, getGpuStatus }; diff --git a/paintplus/frontend/src/js/app.js b/paintplus/frontend/src/js/app.js new file mode 100644 index 0000000..f7f8ee8 --- /dev/null +++ b/paintplus/frontend/src/js/app.js @@ -0,0 +1,11 @@ +// Store singletons for easy access +export default { + GUI: null, + Tools: null, + Layers: null, + Config: null, + State: null, + FileOpen: null, + FileSave: null, + Actions: null +}; \ No newline at end of file diff --git a/paintplus/frontend/src/js/config-menu.js b/paintplus/frontend/src/js/config-menu.js new file mode 100644 index 0000000..48e42dd --- /dev/null +++ b/paintplus/frontend/src/js/config-menu.js @@ -0,0 +1,916 @@ +const menuDefinition = [ + { + name: 'File', + children: [ + { + name: 'New', + target: 'file/new.new' + }, + { + divider: true + }, + { + name: 'Open', + children: [ + { + name: 'Open File', + shortcut: 'O', + ellipsis: true, + target: 'file/open.open_file' + }, + { + name: 'Open Directory', + ellipsis: true, + target: 'file/open.open_dir' + }, + { + name: 'Open from Webcam', + target: 'file/open.open_webcam' + }, + { + name: 'Open URL', + ellipsis: true, + target: 'file/open.open_url' + }, + { + name: 'Open Data URL', + ellipsis: true, + target: 'file/open.open_data_url' + }, + { + name: 'Open Test Template', + target: 'file/open.open_template_test' + } + ] + }, + { + name: 'Search Images', + ellipsis: true, + target: 'file/open.search' + }, + { + divider: true + }, + { + name: 'Export', + ellipsis: true, + shortcut: 'S', + target: 'file/save.export' + }, + { + name: 'Save As', + ellipsis: true, + shortcut: 'Shift + S', + target: 'file/save.save' + }, + { + name: 'Save As Data URL', + ellipsis: true, + target: 'file/save.save_data_url' + }, + { + name: 'Print', + ellipsis: true, + shortcut: 'Ctrl+P', + target: 'file/print.print' + }, + { + divider: true + }, + { + name: 'Quick Save', + shortcut: 'F9', + target: 'file/quicksave.quicksave' + }, + { + name: 'Quick Load', + shortcut: 'F10', + target: 'file/quickload.quickload' + }, + { + divider: true + }, + { + name: 'My Library', + children: [ + { + name: 'Browse Library', + ellipsis: true, + target: 'file/my_library.browse_library' + }, + { + divider: true + }, + { + name: 'Save Layer to Library', + ellipsis: true, + target: 'file/my_library.save_to_library' + }, + { + name: 'Save Selection to Library', + ellipsis: true, + target: 'file/my_library.save_selection_to_library' + }, + { + divider: true + }, + { + name: 'Export Library (Backup)', + target: 'file/my_library.export_library' + }, + { + name: 'Import Library', + ellipsis: true, + target: 'file/my_library.import_library' + } + ] + } + ] + }, + { + name: 'Edit', + children: [ + { + name: 'Undo', + shortcut: 'Ctrl+Z', + target: 'edit/undo.undo' + }, + { + name: 'Redo', + shortcut: 'Ctrl+Y', + target: 'edit/redo.redo' + }, + { + name: 'History Panel', + shortcut: 'Ctrl+H', + target: 'edit/history_panel.toggle' + }, + { + divider: true + }, + { + name: 'Delete Selection', + shortcut: 'Del', + target: 'edit/selection.delete' + }, + { + name: 'Copy Selection', + target: 'layer/new.new_selection' + }, + { + name: 'Copy to Clipboard', + shortcut: 'Ctrl+C', + target: 'edit/copy.copy_to_clipboard' + }, + { + name: 'Paste', + shortcut: 'Ctrl+V', + target: 'edit/paste.paste' + }, + { + divider: true + }, + { + name: 'Select All', + shortcut: 'Ctrl+A', + target: 'edit/selection.select_all' + } + ] + }, + { + name: 'View', + children: [ + { + name: 'Zoom', + children: [ + { + name: 'Zoom In', + target: 'view/zoom.in' + }, + { + name: 'Zoom Out', + target: 'view/zoom.out' + }, + { + divider: true + }, + { + name: 'Original Size', + target: 'view/zoom.original' + }, + { + name: 'Fit Window', + target: 'view/zoom.auto' + } + ] + }, + { + name: 'Grid', + shortcut: 'G', + target: 'view/grid.grid' + }, + { + name: 'Guides', + children: [ + { + name: 'Insert', + ellipsis: true, + target: 'view/guides.insert' + }, + { + name: 'Update', + target: 'view/guides.update' + }, + { + name: 'Remove all', + target: 'view/guides.remove' + } + ] + }, + { + name: 'Ruler', + target: 'view/ruler.ruler' + }, + { + divider: true + }, + { + name: 'Full Screen', + target: 'view/full_screen.fs' + } + ] + }, + { + name: 'Image', + children: [ + { + name: 'Information', + shortcut: 'I', + ellipsis: true, + target: 'image/information.information' + }, + { + name: 'Canvas Size', + ellipsis: true, + target: 'image/size.size' + }, + { + name: 'Trim', + ellipsis: true, + shortcut: 'T', + target: 'image/trim.trim' + }, + { + divider: true + }, + { + name: 'Resize', + ellipsis: true, + shortcut: 'R', + target: 'image/resize.resize' + }, + { + name: 'Rotate', + ellipsis: true, + target: 'image/rotate.rotate' + }, + { + name: 'Flip', + children: [ + { + name: 'Vertical', + target: 'image/flip.vertical' + }, + { + name: 'Horizontal', + target: 'image/flip.horizontal' + } + ] + }, + { + name: 'Translate', + ellipsis: true, + target: 'image/translate.translate' + }, + { + name: 'Opacity', + ellipsis: true, + target: 'image/opacity.opacity' + }, + { + divider: true + }, + { + name: 'Color Corrections', + ellipsis: true, + target: 'image/color_corrections.color_corrections' + }, + { + name: 'Auto Adjust Colors', + shortcut: 'F', + target: 'image/auto_adjust.auto_adjust' + }, + { + name: 'Decrease Color Depth', + target: 'image/decrease_colors.decrease_colors' + }, + { + name: 'Color Palette', + ellipsis: true, + target: 'image/palette.palette' + }, + { + divider: true + }, + { + name: 'Histogram', + ellipsis: true, + target: 'image/histogram.histogram' + }, + { + divider: true + }, + { + name: 'Auto-Enhance', + ellipsis: true, + target: 'image/auto_enhance.auto_enhance' + }, + { + name: 'Extract Color Palette', + target: 'image/color_palette.color_palette' + }, + { + name: 'Remove Background (AI)', + ellipsis: true, + target: 'image/remove_background.remove_background' + }, + { + name: 'Replace Subject (AI)...', + ellipsis: true, + target: 'image/replace_subject.replace_subject' + }, + { + name: 'Prepare for Print...', + ellipsis: true, + target: 'image/print_prepare.print_prepare' + }, + { + name: 'Fit to Frame...', + ellipsis: true, + target: 'image/frame_fit.frame_fit' + }, + { + name: 'Upscale...', + ellipsis: true, + target: 'image/upscale.upscale' + }, + { + divider: true + }, + { + name: 'Selection Effects', + children: [ + { + name: 'Invert Selection', + ellipsis: true, + target: 'image/selection_effects.invert_selection' + }, + { + name: 'Adjust Selection', + ellipsis: true, + target: 'image/selection_effects.adjust_selection' + }, + { + name: 'Greyscale Selection', + ellipsis: true, + target: 'image/selection_effects.greyscale_selection' + } + ] + } + ] + }, + { + name: 'Layer', + children: [ + { + name: 'New', + shortcut: 'N', + target: 'layer/new.new' + }, + { + name: 'New from Selection', + target: 'layer/new.new_selection' + }, + { + divider: true + }, + { + name: 'Duplicate', + shortcut: 'D', + target: 'layer/duplicate.duplicate' + }, + { + name: 'Show / Hide', + target: 'layer/visibility.toggle' + }, + { + name: 'Delete', + target: 'layer/delete.delete' + }, + { + name: 'Convert to Raster', + target: 'layer/raster.raster' + }, + { + name: 'Scale Layer', + ellipsis: true, + target: 'layer/scale.scale' + }, + { + name: 'Align to Canvas', + target: 'layer/align.align' + }, + { + divider: true + }, + { + name: 'Move', + children: [ + { + name: 'Up', + target: 'layer/move.up' + }, + { + name: 'Down', + target: 'layer/move.down' + } + ] + }, + { + name: 'Composition', + ellipsis: true, + target: 'layer/composition.composition' + }, + { + name: 'Rename', + ellipsis: true, + target: 'layer/rename.rename' + }, + { + name: 'Clear', + target: 'layer/clear.clear' + }, + { + divider: true + }, + { + name: 'Differences Down', + target: 'layer/differences.differences' + }, + { + name: 'Merge Down', + target: 'layer/merge.merge' + }, + { + name: 'Flatten Image', + target: 'layer/flatten.flatten' + } + ] + }, + { + name: 'Effects', + children: [ + { + name: 'Effect browser', + ellipsis: true, + target: 'effects/browser.browser' + }, + { + divider: true + }, + { + name: 'Common Filters', + children: [ + { + name: 'Gaussian Blur', + ellipsis: true, + target: 'effects/common/blur.blur' + }, + { + name: 'Brightness', + ellipsis: true, + target: 'effects/common/brightness.brightness' + }, + { + name: 'Contrast', + ellipsis: true, + target: 'effects/common/contrast.contrast' + }, + { + name: 'Grayscale', + ellipsis: true, + target: 'effects/common/grayscale.grayscale' + }, + { + name: 'Hue Rotate', + ellipsis: true, + target: 'effects/common/hue-rotate.hue_rotate' + }, + { + name: 'Negative', + ellipsis: true, + target: 'effects/common/invert.invert' + }, + { + name: 'Saturate', + ellipsis: true, + target: 'effects/common/saturate.saturate' + }, + { + name: 'Sepia', + ellipsis: true, + target: 'effects/common/sepia.sepia' + }, + { + name: 'Shadow', + ellipsis: true, + target: 'effects/common/shadow.shadow' + }, + ] + }, + { + name: 'Instagram Filters', + children: [ + { + name: '1977', + target: 'effects/instagram/1977.1977' + }, + { + name: 'Aden', + target: 'effects/instagram/aden.aden' + }, + { + name: 'Clarendon', + target: 'effects/instagram/clarendon.clarendon' + }, + { + name: 'Gingham', + target: 'effects/instagram/gingham.gingham' + }, + { + name: 'Inkwell', + target: 'effects/instagram/inkwell.inkwell' + }, + { + name: 'Lo-fi', + target: 'effects/instagram/lofi.lofi' + }, + { + name: 'Toaster', + target: 'effects/instagram/toaster.toaster' + }, + { + name: 'Valencia', + target: 'effects/instagram/valencia.valencia' + }, + { + name: 'X-Pro II', + target: 'effects/instagram/xpro2.xpro2' + } + ] + }, + { + name: 'Black and White', + ellipsis: true, + target: 'effects/black_and_white.black_and_white' + }, + { + name: 'Greyscale', + ellipsis: true, + target: 'effects/greyscale.greyscale' + }, + { + name: 'Borders', + ellipsis: true, + target: 'effects/borders.borders' + }, + { + name: 'Blueprint', + target: 'effects/blueprint.blueprint' + }, + { + name: 'Box Blur', + ellipsis: true, + target: 'effects/box_blur.box_blur' + }, + { + name: 'Denoise', + ellipsis: true, + target: 'effects/denoise.denoise' + }, + { + name: 'Dither', + ellipsis: true, + target: 'effects/dither.dither' + }, + { + name: 'Dot Screen', + ellipsis: true, + target: 'effects/dot_screen.dot_screen' + }, + { + name: 'Edge', + target: 'effects/edge.edge' + }, + { + name: 'Emboss', + target: 'effects/emboss.emboss' + }, + { + name: 'Enrich', + ellipsis: true, + target: 'effects/enrich.enrich' + }, + { + name: 'Grains', + ellipsis: true, + target: 'effects/grains.grains' + }, + { + name: 'Heatmap', + target: 'effects/heatmap.heatmap' + }, + { + name: 'Mosaic', + ellipsis: true, + target: 'effects/mosaic.mosaic' + }, + { + name: 'Night Vision', + target: 'effects/night_vision.night_vision' + }, + { + name: 'Oil', + ellipsis: true, + target: 'effects/oil.oil' + }, + { + name: 'Pencil', + target: 'effects/pencil.pencil' + }, + { + name: 'Sharpen', + ellipsis: true, + target: 'effects/sharpen.sharpen' + }, + { + name: 'Solarize', + target: 'effects/solarize.solarize' + }, + { + name: 'Tilt Shift', + ellipsis: true, + target: 'effects/tilt_shift.tilt_shift' + }, + { + name: 'Vignette', + ellipsis: true, + target: 'effects/vignette.vignette' + }, + { + name: 'Vibrance', + ellipsis: true, + target: 'effects/vibrance.vibrance' + }, + { + name: 'Vintage', + ellipsis: true, + target: 'effects/vintage.vintage' + }, + { + name: 'Zoom Blur', + ellipsis: true, + target: 'effects/zoom_blur.zoom_blur' + } + ] + }, + { + name: 'Tools', + children: [ + { + name: 'Sprites', + target: 'tools/sprites.sprites' + }, + { + name: 'Key-Points', + target: 'tools/keypoints.keypoints' + }, + { + name: 'Content Fill', + ellipsis: true, + target: 'tools/content_fill.content_fill' + }, + { + divider: true + }, + { + name: 'Color Zoom', + ellipsis: true, + target: 'tools/color_zoom.color_zoom' + }, + { + name: 'Replace Color', + ellipsis: true, + target: 'tools/replace_color.replace_color' + }, + { + name: 'Restore Alpha', + ellipsis: true, + target: 'tools/restore_alpha.restore_alpha' + }, + { + name: 'External', + children: [ + { + name: 'TINYPNG - Compress PNG and JPEG', + href: 'https://tinypng.com' + }, + { + name: 'REMOVE.BG - Remove Image Background', + href: 'https://www.remove.bg' + }, + { + name: 'PNGTOSVG - Convert Image to SVG', + href: 'https://www.pngtosvg.com' + }, + { + name: 'SQUOOSH - Compress and Compare Images', + href: 'https://squoosh.app' + } + ] + }, + { + divider: true + }, + { + name: 'Language', + children: [ + { + name: 'English', + target: 'tools/translate.translate', + parameter: 'en', + }, + { + divider: true + }, + { + //Arabic + name: 'عربي', + target: 'tools/translate.translate', + parameter: 'ar', + }, + { + //Chinese simplified + name: '简体中文', + target: 'tools/translate.translate', + parameter: 'zh', + }, + { + name: 'Deutsch', + target: 'tools/translate.translate', + parameter: 'de', + }, + { + name: 'Dutch', + target: 'tools/translate.translate', + parameter: 'nl', + }, + { + name: 'English (UK)', + target: 'tools/translate.translate', + parameter: 'uk', + }, + { + name: 'Español', + target: 'tools/translate.translate', + parameter: 'es', + }, + { + name: 'Français', + target: 'tools/translate.translate', + parameter: 'fr', + }, + { + name: 'Greek', + target: 'tools/translate.translate', + parameter: 'el', + }, + { + name: 'Italiano', + target: 'tools/translate.translate', + parameter: 'it', + }, + { + //Japanese + name: '日本語', + target: 'tools/translate.translate', + parameter: 'ja', + }, + { + //Korean + name: '한국어', + target: 'tools/translate.translate', + parameter: 'ko', + }, + { + name: 'Lietuvių', + target: 'tools/translate.translate', + parameter: 'lt', + }, + { + name: 'Português', + target: 'tools/translate.translate', + parameter: 'pt', + }, + { + name: 'русский язык', + target: 'tools/translate.translate', + parameter: 'ru', + }, + { + name: 'Türkçe', + target: 'tools/translate.translate', + parameter: 'tr', + } + ] + }, + { + name: 'Search', + shortcut: 'F3', + ellipsis: true, + target: 'tools/search.search' + }, + { + name: 'Settings', + ellipsis: true, + target: 'tools/settings.settings' + }, + { + divider: true + }, + { + name: 'AI Provider Settings', + ellipsis: true, + target: 'tools/ai_provider_settings.ai_provider_settings' + } + ] + }, + { + name: 'Generate', + children: [ + { + name: 'Add Text', + ellipsis: true, + target: 'text/text_presets.add_preset' + }, + { + divider: true + }, + { + name: 'Text → Image', + ellipsis: true, + target: 'generate/text_to_image.text_to_image' + }, + { + name: 'Expand Canvas (Outpaint)', + ellipsis: true, + target: 'generate/outpaint.outpaint' + }, + ] + }, + { + name: 'Help', + children: [ + { + name: 'Keyboard Shortcuts', + ellipsis: true, + target: 'help/shortcuts.shortcuts' + }, + { + name: 'Report Issues', + href: 'https://github.com/viliusle/miniPaint/issues' + }, + { + divider: true + }, + { + name: 'About', + ellipsis: true, + target: 'help/about.about' + } + ] + } +]; + + +export default menuDefinition; \ No newline at end of file diff --git a/paintplus/frontend/src/js/config.js b/paintplus/frontend/src/js/config.js new file mode 100644 index 0000000..6678895 --- /dev/null +++ b/paintplus/frontend/src/js/config.js @@ -0,0 +1,559 @@ +//main config file + +var config = {}; + +config.TRANSPARENCY = false; +config.TRANSPARENCY_TYPE = 'squares'; //squares, green, grey +config.LANG = 'en'; +config.WIDTH = null; +config.HEIGHT = null; +config.visible_width = null; +config.visible_height = null; +config.COLOR = '#008000'; +config.ALPHA = 255; +config.ZOOM = 1; +config.SNAP = true; +config.pixabay_key = '3ca2cd8af3fde33af218bea02-9021417'; +config.safe_search_can_be_disabled = true; +config.google_webfonts_key = 'AIzaSyAC_Tx8RKkvN235fXCUyi_5XhSaRCzNhMg'; +config.layers = []; +config.layer = null; +config.need_render = false; +config.need_render_changed_params = false; // Set specifically when param change in layer details triggered render +config.mouse = {}; +config.mouse_lock = null; +config.swatches = { + default: [] // Only default used right now, object format for swatch swapping in future. +}; +config.user_fonts = {}; +config.guides_enabled = true; +config.guides = []; +config.ruler_active = false; +config.enable_autoresize_by_default = true; + +//requires styles in reset.css +config.themes = [ + 'dark', + 'light', + 'green', +]; + +//no-translate BEGIN +config.FONTS = [ + "Arial", + "Courier", + "Impact", + "Helvetica", + "Monospace", + "Tahoma", + "Times New Roman", + "Verdana", + "Amatic SC", + "Arimo", + "Codystar", + "Creepster", + "Indie Flower", + "Lato", + "Lora", + "Merriweather", + "Monoton", + "Montserrat", + "Mukta", + "Muli", + "Nosifer", + "Nunito", + "Oswald", + "Orbitron", + "Pacifico", + "PT Sans", + "PT Serif", + "Playfair Display", + "Poppins", + "Raleway", + "Roboto", + "Rubik", + "Special Elite", + "Tangerine", + "Titillium Web", + "Ubuntu" +]; +//no-translate END + +config.TOOLS = [ + { + name: 'select', + title: 'Select object tool', + on_activate: 'on_activate', + attributes: { + auto_select: true, + keep_ratio: true, + }, + }, + { + name: 'selection', + attributes: {}, + on_leave: 'on_leave', + }, + { + name: 'smart_select', + title: 'Smart Select (AI) - Click to select', + attributes: {}, + }, + { + name: 'brush_select', + title: 'Brush Select (AI) - Paint over to select', + attributes: {}, + }, + { + name: 'ai_edit', + title: 'AI Edit — paint mask, then Erase / Replace / Upscale / Expand', + on_activate: 'on_activate', + attributes: { + size: { + value: 30, + min: 5, + max: 200, + }, + }, + }, + { + name: 'magic_wand', + title: 'Magic Wand (Color Select)', + attributes: { + tolerance: { + value: 30, + min: 0, + max: 100, + }, + contiguous: true, + }, + }, + { + name: 'lasso', + title: 'Lasso (Freehand Select)', + attributes: {}, + }, + { + name: 'ellipse_select', + title: 'Ellipse Selection', + attributes: {}, + }, + { + name: 'brush', + attributes: { + size: 4, + pressure: false, + }, + }, + { + name: 'pencil', + attributes: { + size: 1, + pressure: false, + }, + }, + { + name: 'pick_color', + attributes: { + global: false, + }, + }, + { + name: 'erase', + on_update: 'on_params_update', + attributes: { + size: 30, + circle: true, + strict: true, + }, + }, + { + name: 'magic_erase', + title: 'Magic Eraser Tool', + attributes: { + power: 15, + anti_aliasing: true, + contiguous: false, + }, + }, + { + name: 'fill', + attributes: { + power: 5, + anti_aliasing: false, + contiguous: false, + }, + }, + { + name: 'shape', + on_activate: 'on_activate', + title: 'Shapes (H)', + attributes: { + size: 3, + stroke: '#00aa00', + }, + }, + { + name: 'line', + visible: false, + attributes: { + size: 4, + }, + }, + { + name: 'arrow', + visible: false, + attributes: { + size: 4, + }, + }, + { + name: 'rectangle', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + radius: { + value: 0, + min: 0, + }, + square: false, + }, + }, + { + name: 'ellipse', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + circle: false, + }, + }, + { + name: 'media', + title: 'Search Images', + on_activate: 'on_activate', + attributes: { + size: 30, + }, + }, + { + name: 'triangle', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'right_triangle', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'romb', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'parallelogram', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'trapezoid', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'plus', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'pentagon', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'hexagon', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'star', + visible: false, + attributes: { + border_size: 4, + corners: 5, + inner_radius: 40, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'heart', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'cylinder', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'human', + visible: false, + attributes: { + border_size: 4, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'tear', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'cog', + visible: false, + attributes: { + fill_color: '#555555', + }, + }, + { + name: 'bezier_curve', + visible: false, + attributes: { + size: 4, + }, + }, + { + name: 'moon', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'callout', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, + { + name: 'text', + on_update: 'on_params_update', + attributes: { + font: { + value: 'Arial', + values() { + const user_font_names = Object.keys(config.user_fonts); + return ['', '[Add Font...]', ...Array.from(new Set([...config.FONTS, ...user_font_names].sort()))]; + } + }, + size: 40, + bold: { + value: false, + icon: `bold.svg` + }, + italic: { + value: false, + icon: `italic.svg` + }, + underline: { + value: false, + icon: `underline.svg` + }, + strikethrough: { + value: false, + icon: `strikethrough.svg` + }, + fill: '#008800', + stroke: '#000000', + stroke_size: { + value: 0, + min: 0, + step: 0.1 + }, + kerning: { + value: 0, + min: -999, + max: 999, + step: 1 + }, + leading: { + value: 0, + min: -999, + max: 999, + step: 1 + } + }, + }, + { + name: 'gradient', + attributes: { + color_1: '#008000', + color_2: '#ffffff', + alpha: 0, + radial: false, + radial_power: 50, + }, + }, + { + name: 'clone', + attributes: { + size: 30, + anti_aliasing: true, + source_layer: { + value: 'Current', + values: ['Current', 'Previous'], + }, + }, + }, + { + name: 'crop', + on_update: 'on_params_update', + on_leave: 'on_leave', + attributes: { + crop: true, + }, + }, + { + name: 'blur', + attributes: { + size: 30, + strength: 1, + }, + }, + { + name: 'sharpen', + attributes: { + size: 30, + }, + }, + { + name: 'desaturate', + attributes: { + size: 50, + anti_aliasing: true, + }, + }, + { + name: 'bulge_pinch', + title: 'Bulge/Pinch Tool', + attributes: { + radius: 80, + power: 50, + bulge: true, + }, + }, + { + name: 'animation', + on_activate: 'on_activate', + on_update: 'on_params_update', + on_leave: 'on_leave', + attributes: { + play: false, + delay: 400, + }, + }, + { + name: 'polygon', + visible: false, + attributes: { + border_size: 4, + border: true, + fill: true, + border_color: '#555555', + fill_color: '#aaaaaa', + }, + }, +]; + +//link to active tool +config.TOOL = config.TOOLS[2]; + +export default config; \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/base-gui.js b/paintplus/frontend/src/js/core/base-gui.js new file mode 100644 index 0000000..ad11caf --- /dev/null +++ b/paintplus/frontend/src/js/core/base-gui.js @@ -0,0 +1,522 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../config.js'; +import Base_layers_class from './base-layers.js'; +import GUI_tools_class from './gui/gui-tools.js'; +import GUI_preview_class from './gui/gui-preview.js'; +import GUI_colors_class from './gui/gui-colors.js'; +import GUI_layers_class from './gui/gui-layers.js'; +import GUI_information_class from './gui/gui-information.js'; +import GUI_details_class from './gui/gui-details.js'; +import GUI_menu_class from './gui/gui-menu.js'; +import Tools_translate_class from './../modules/tools/translate.js'; +import Tools_settings_class from './../modules/tools/settings.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +/** + * Main GUI class + */ +class Base_gui_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Helper = new Helper_class(); + this.Base_layers = new Base_layers_class(); + + //last used menu id + this.last_menu = ''; + + //grid dimensions config + this.grid_size = [50, 50]; + + //if grid is visible + this.grid = false; + + this.canvas_offset = {x: 0, y: 0}; + + //common image dimensions + this.common_dimensions = [ + [640, 480, '480p'], + [800, 600, 'SVGA'], + [1024, 768, 'XGA'], + [1280, 720, 'hdtv, 720p'], + [1600, 1200, 'UXGA'], + [1920, 1080, 'Full HD, 1080p'], + [3840, 2160, '4K UHD'], + //[7680,4320, '8K UHD'], + ]; + + this.GUI_tools = new GUI_tools_class(this); + this.GUI_preview = new GUI_preview_class(this); + this.GUI_colors = new GUI_colors_class(this); + this.GUI_layers = new GUI_layers_class(this); + this.GUI_information = new GUI_information_class(this); + this.GUI_details = new GUI_details_class(this); + this.GUI_menu = new GUI_menu_class(); + this.Tools_translate = new Tools_translate_class(); + this.Tools_settings = new Tools_settings_class(); + this.modules = {}; + } + + init() { + this.load_modules(); + this.load_default_values(); + this.render_main_gui(); + this.init_service_worker(); + } + + load_modules() { + var _this = this; + var modules_context = require.context("./../modules/", true, /\.js$/); + modules_context.keys().forEach(function (key) { + if (key.indexOf('Base' + '/') < 0) { + var moduleKey = key.replace('./', '').replace('.js', ''); + try { + var classObj = modules_context(key); + _this.modules[moduleKey] = new classObj.default(); + } catch (e) { + console.error('[load_modules] Failed to load ' + key + ':', e); + } + } + }); + } + + load_default_values() { + //transparency + var transparency_cookie = this.Helper.getCookie('transparency'); + if (transparency_cookie === null) { + //default + config.TRANSPARENCY = false; + } + if (transparency_cookie) { + config.TRANSPARENCY = true; + } + else { + config.TRANSPARENCY = false; + } + + //transparency_type + var transparency_type = this.Helper.getCookie('transparency_type'); + if (transparency_type === null) { + //default + config.TRANSPARENCY_TYPE = 'squares'; + } + if (transparency_type) { + config.TRANSPARENCY_TYPE = transparency_type; + } + + //snap + var snap_cookie = this.Helper.getCookie('snap'); + if (snap_cookie === null) { + //default + config.SNAP = true; + } + else{ + config.SNAP = Boolean(snap_cookie); + } + + //guides + var guides_cookie = this.Helper.getCookie('guides'); + if (guides_cookie === null) { + //default + config.guides_enabled = true; + } + else{ + config.guides_enabled = Boolean(guides_cookie); + } + } + + render_main_gui() { + this.autodetect_dimensions(); + + this.change_theme(); + this.prepare_canvas(); + this.GUI_tools.render_main_tools(); + this.GUI_preview.render_main_preview(); + this.GUI_colors.render_main_colors(); + this.GUI_layers.render_main_layers(); + this.GUI_information.render_main_information(); + this.GUI_details.render_main_details(); + this.GUI_menu.render_main(); + this.load_saved_changes(); + + this.set_events(); + this.load_translations(); + } + + init_service_worker() { + /*if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('./service-worker.js').then(function(reg) { + //Successfully registered service worker + }).catch(function(err) { + console.warn('Error registering service worker', err); + }); + }*/ + } + + set_events() { + var _this = this; + + //menu events + this.GUI_menu.on('select_target', (target, object) => { + var parts = target.split('.'); + var module = parts[0]; + var function_name = parts[1]; + var param = object.parameter ??= null; + + //call module + if (this.modules[module] == undefined) { + alertify.error('Modules class not found: ' + module); + return; + } + if (this.modules[module][function_name] == undefined) { + alertify.error('Module function not found. ' + module + '.' + function_name); + return; + } + this.modules[module][function_name](param); + }); + + //registerToggleAbility + var targets = document.querySelectorAll('.toggle'); + for (var i = 0; i < targets.length; i++) { + if (targets[i].dataset.target == undefined) + continue; + targets[i].addEventListener('click', function (event) { + this.classList.toggle('toggled'); + var target = document.getElementById(this.dataset.target); + target.classList.toggle('hidden'); + //save + if (target.classList.contains('hidden') == false) + _this.Helper.setCookie(this.dataset.target, 1); + else + _this.Helper.setCookie(this.dataset.target, 0); + }); + } + + document.getElementById('left_mobile_menu_button').addEventListener('click', function (event) { + document.querySelector('.sidebar_left').classList.toggle('active'); + }); + document.getElementById('mobile_menu_button').addEventListener('click', function (event) { + document.querySelector('.sidebar_right').classList.toggle('active'); + }); + window.addEventListener('resize', function (event) { + //resize + _this.prepare_canvas(); + config.need_render = true; + }, false); + this.check_canvas_offset(); + + //confirmation on exit + var exit_confirm = this.Tools_settings.get_setting('exit_confirm'); + window.addEventListener('beforeunload', function (e) { + if(exit_confirm && (config.layers.length > 1 || _this.Base_layers.is_layer_empty(config.layer.id) == false)){ + e.preventDefault(); + e.returnValue = ''; + } + return undefined; + }); + + document.getElementById('canvas_minipaint').addEventListener('contextmenu', function (e) { + e.preventDefault(); + }, false); + } + + check_canvas_offset() { + //calc canvas position offset + var bodyRect = document.body.getBoundingClientRect(); + var canvas_el = document.getElementById('canvas_minipaint').getBoundingClientRect(); + this.canvas_offset.x = canvas_el.left - bodyRect.left; + this.canvas_offset.y = canvas_el.top - bodyRect.top; + } + + prepare_canvas() { + var canvas = document.getElementById('canvas_minipaint'); + var ctx = canvas.getContext("2d"); + + var wrapper = document.getElementById('main_wrapper'); + var page_w = wrapper.clientWidth; + var page_h = wrapper.clientHeight; + + var w = Math.min(Math.ceil(config.WIDTH * config.ZOOM), page_w); + var h = Math.min(Math.ceil(config.HEIGHT * config.ZOOM), page_h); + + canvas.width = w; + canvas.height = h; + + config.visible_width = w; + config.visible_height = h; + + if(config.ZOOM >= 1) { + ctx.imageSmoothingEnabled = false; + } + else{ + ctx.imageSmoothingEnabled = true; + } + + this.render_canvas_background('canvas_minipaint'); + + //change wrapper dimensions + document.getElementById('canvas_wrapper').style.width = w + 'px'; + document.getElementById('canvas_wrapper').style.height = h + 'px'; + + this.check_canvas_offset(); + } + + load_saved_changes() { + var targets = document.querySelectorAll('.toggle'); + for (var i = 0; i < targets.length; i++) { + if (targets[i].dataset.target == undefined) + continue; + + var target = document.getElementById(targets[i].dataset.target); + var saved = this.Helper.getCookie(targets[i].dataset.target); + if (saved === 0) { + targets[i].classList.toggle('toggled'); + target.classList.add('hidden'); + } + } + } + + load_translations() { + var lang = this.Helper.getCookie('language'); + + //load from params + var params = this.Helper.get_url_parameters(); + if(params.lang != undefined){ + lang = params.lang.replace(/([^a-z]+)/gi, ''); + } + + if (lang != null && lang != config.LANG) { + config.LANG = lang.replace(/([^a-z]+)/gi, ''); + this.Tools_translate.translate(config.LANG); + } + } + + autodetect_dimensions() { + var wrapper = document.getElementById('main_wrapper'); + var page_w = wrapper.clientWidth; + var page_h = wrapper.clientHeight; + var auto_size = false; + + //use largest possible + for (var i = this.common_dimensions.length - 1; i >= 0; i--) { + if (this.common_dimensions[i][0] > page_w + || this.common_dimensions[i][1] > page_h) { + //browser size is too small + continue; + } + config.WIDTH = parseInt(this.common_dimensions[i][0]); + config.HEIGHT = parseInt(this.common_dimensions[i][1]); + auto_size = true; + break; + } + + if (auto_size == false) { + //screen size is smaller then 400x300 + config.WIDTH = parseInt(page_w) - 15; + config.HEIGHT = parseInt(page_h) - 10; + } + } + + render_canvas_background(canvas_id, gap) { + if (gap == undefined) + gap = 10; + + var target = document.getElementById(canvas_id + '_background'); + + if (config.TRANSPARENCY == false) { + target.className = 'transparent-grid white'; + return false; + } + else{ + target.className = 'transparent-grid ' + config.TRANSPARENCY_TYPE; + } + target.style.backgroundSize = (gap * 2) + 'px auto'; + } + + draw_grid(ctx) { + if (this.grid == false) + return; + + var gap_x = this.grid_size[0]; + var gap_y = this.grid_size[1]; + + var width = config.WIDTH; + var height = config.HEIGHT; + + //size + if (gap_x != undefined && gap_y != undefined) + this.grid_size = [gap_x, gap_y]; + else { + gap_x = this.grid_size[0]; + gap_y = this.grid_size[1]; + } + gap_x = parseInt(gap_x); + gap_y = parseInt(gap_y); + ctx.lineWidth = 1; + ctx.beginPath(); + if (gap_x < 2) + gap_x = 2; + if (gap_y < 2) + gap_y = 2; + for (var i = gap_x; i < width; i = i + gap_x) { + if (gap_x == 0) + break; + if (i % (gap_x * 5) == 0) { + //main lines + ctx.strokeStyle = '#222222'; + } + else { + //small lines + ctx.strokeStyle = '#bbbbbb'; + } + ctx.beginPath(); + ctx.moveTo(0.5 + i, 0); + ctx.lineTo(0.5 + i, height); + ctx.stroke(); + } + for (var i = gap_y; i < height; i = i + gap_y) { + if (gap_y == 0) + break; + if (i % (gap_y * 5) == 0) { + //main lines + ctx.strokeStyle = '#222222'; + } + else { + //small lines + ctx.strokeStyle = '#bbbbbb'; + } + ctx.beginPath(); + ctx.moveTo(0, 0.5 + i); + ctx.lineTo(width, 0.5 + i); + ctx.stroke(); + } + } + + draw_guides(ctx){ + if(config.guides_enabled == false){ + return; + } + var thick_guides = this.Tools_settings.get_setting('thick_guides'); + + for(var i in config.guides) { + var guide = config.guides[i]; + + if (guide.x === 0 || guide.y === 0) { + continue; + } + + //set styles + ctx.strokeStyle = '#00b8b8'; + if(thick_guides == false) + ctx.lineWidth = 1; + else + ctx.lineWidth = 3; + + ctx.beginPath(); + if (guide.y === null) { + //vertical + ctx.moveTo(guide.x, 0); + ctx.lineTo(guide.x, config.HEIGHT); + } + if (guide.x === null) { + //horizontal + ctx.moveTo(0, guide.y); + ctx.lineTo(config.WIDTH, guide.y); + } + ctx.stroke(); + } + } + + /** + * change draw area size + * + * @param {int} width + * @param {int} height + */ + set_size(width, height) { + config.WIDTH = parseInt(width); + config.HEIGHT = parseInt(height); + this.prepare_canvas(); + } + + /** + * + * @returns {object} keys: width, height + */ + get_visible_area_size() { + var wrapper = document.getElementById('main_wrapper'); + var page_w = wrapper.clientWidth; + var page_h = wrapper.clientHeight; + + //find visible size in pixels, but make sure its correct even if image smaller then screen + var w = Math.min(Math.ceil(config.WIDTH * config.ZOOM), Math.ceil(page_w / config.ZOOM)); + var h = Math.min(Math.ceil(config.HEIGHT * config.ZOOM), Math.ceil(page_h / config.ZOOM)); + + return { + width: w, + height: h, + }; + } + + /** + * change theme or set automatically from cookie if possible + * + * @param {string} theme_name + */ + change_theme(theme_name = null){ + if(theme_name == null){ + //auto detect + var theme_cookie = this.Helper.getCookie('theme'); + if (theme_cookie) { + theme_name = theme_cookie; + } + else { + theme_name = this.Tools_settings.get_setting('theme'); + } + } + + for(var i in config.themes){ + document.querySelector('body').classList.remove('theme-' + config.themes[i]); + } + document.querySelector('body').classList.add('theme-' + theme_name); + } + + get_language() { + return config.LANG; + } + + get_color() { + return config.COLOR; + } + + get_alpha() { + return config.ALPHA; + } + + get_zoom() { + return config.ZOOM; + } + + get_transparency_support() { + return config.TRANSPARENCY; + } + + get_active_tool() { + return config.TOOL; + } + +} + +export default Base_gui_class; diff --git a/paintplus/frontend/src/js/core/base-layers.js b/paintplus/frontend/src/js/core/base-layers.js new file mode 100644 index 0000000..e6e7dbc --- /dev/null +++ b/paintplus/frontend/src/js/core/base-layers.js @@ -0,0 +1,884 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import app from "./../app.js"; +import config from "./../config.js"; +import Base_gui_class from "./base-gui.js"; +import Base_selection_class from "./base-selection.js"; +import Image_trim_class from "./../modules/image/trim.js"; +import View_ruler_class from "./../modules/view/ruler.js"; +import zoomView from "./../libs/zoomView.js"; +import Helper_class from "./../libs/helpers.js"; +import alertify from "./../../../node_modules/alertifyjs/build/alertify.min.js"; + +var instance = null; + +/** + * Layers class - manages layers. Each layer is object with various types. Keys: + * - id (int) + * - link (image) + * - parent_id (int) + * - name (string) + * - type (string) + * - x (int) + * - y (int) + * - width (int) + * - height (int) + * - width_original (int) + * - height_original (int) + * - visible (bool) + * - is_vector (bool) + * - hide_selection_if_active (bool) + * - opacity (0-100) + * - order (int) + * - composition (string) + * - rotate (int) 0-359 + * - data (various data here) + * - params (object) + * - color {hex} + * - status (string) + * - filters (array) + * - render_function (function) + */ +class Base_layers_class { + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.Image_trim = new Image_trim_class(); + this.View_ruler = new View_ruler_class(); + + this.canvas = document.getElementById("canvas_minipaint"); + this.ctx = document.getElementById("canvas_minipaint").getContext("2d"); + this.ctx_preview = document + .getElementById("canvas_preview") + .getContext("2d"); + this.last_zoom = 1; + this.auto_increment = 1; + this.stable_dimensions = []; + this.debug_rendering = false; + this.render_success = null; + this.disabled_filter_id = null; + } + + /** + * do preparation on start + */ + init() { + this.init_zoom_lib(); + + new app.Actions.Insert_layer_action({}).do(); + + var sel_config = { + enable_background: false, + enable_borders: true, + enable_controls: false, + enable_rotation: false, + enable_move: false, + data_function: function () { + return config.layer; + }, + }; + this.Base_selection = new Base_selection_class( + this.ctx, + sel_config, + "main" + ); + + this.render(true); + } + + init_zoom_lib() { + zoomView.setBounds(0, 0, config.WIDTH, config.HEIGHT); + zoomView.setContext(this.ctx); + this.stable_dimensions = [config.WIDTH, config.HEIGHT]; + } + + pre_render() { + this.ctx.save(); + zoomView.canvasDefault(); + this.ctx.clearRect( + 0, + 0, + config.WIDTH * config.ZOOM, + config.HEIGHT * config.ZOOM + ); + } + + after_render() { + config.need_render = false; + config.need_render_changed_params = false; + this.ctx.restore(); + zoomView.canvasDefault(); + } + + /** + * renders all layers objects on main canvas + * + * @param {bool} force + */ + render(force) { + var _this = this; + if (force !== true) { + //request render and exit + config.need_render = true; + return; + } + + if ( + this.stable_dimensions[0] != config.WIDTH || + this.stable_dimensions[1] != config.HEIGHT + ) { + //dimensions changed - re-init zoom lib + this.init_zoom_lib(); + } + + if (config.need_render == true) { + this.render_success = null; + + if (this.debug_rendering === true) { + console.log("Rendering..."); + } + + if (this.last_zoom != config.ZOOM) { + //change zoom + zoomView.scaleAt( + this.Base_gui.GUI_preview.zoom_data.x, + this.Base_gui.GUI_preview.zoom_data.y, + config.ZOOM / this.last_zoom + ); + } else if (this.Base_gui.GUI_preview.zoom_data.move_pos != null) { + //move visible window + var pos = this.Base_gui.GUI_preview.zoom_data.move_pos; + var pos_global = zoomView.toScreen(pos); + zoomView.move(-pos_global.x, -pos_global.y); + this.Base_gui.GUI_preview.zoom_data.move_pos = null; + } + + //prepare + this.pre_render(); + + //take data + var layers_sorted = this.get_sorted_layers(); + + zoomView.apply(); + + const newCanvas = this.create_new_canvas( + null, + config.WIDTH, + config.HEIGHT + ); + + this.render_objects(this.ctx, newCanvas, layers_sorted, ()=>{ + this.ctx.save(); + }); + + //grid + this.Base_gui.draw_grid(this.ctx); + + //guides + this.Base_gui.draw_guides(this.ctx); + + //render selected object controls + this.Base_selection.draw_selection(); + + //active tool overlay + this.render_overlay(); + + //render preview + this.render_preview(layers_sorted); + + //reset + this.after_render(); + + this.last_zoom = config.ZOOM; + + this.Base_gui.GUI_details.render_details(); + this.View_ruler.render_ruler(); + + if (this.render_success === false) { + alertify.error("Rendered with errors."); + } + } + + requestAnimationFrame(function () { + _this.render(force); + }); + } + + render_overlay() { + var render_class = config.TOOL.name; + var render_function = "render_overlay"; + + if ( + typeof this.Base_gui.GUI_tools.tools_modules[render_class].object[ + render_function + ] != "undefined" + ) { + this.Base_gui.GUI_tools.tools_modules[render_class].object[ + render_function + ](this.ctx); + } + } + + /** + * LEGACY: use create_new_canvas(); + */ + createNewCanvas(ctx, h, w) { + this.create_new_canvas(ctx, w, h); + } + + /** + * Creates a fresh new canvas with the same height and width as the provided one + * @param {canvas.context|null} ctx + * @param {number} [width] + * @param {number} [height] + */ + create_new_canvas(ctx, width, height) { + const newCanvas = document.createElement("canvas"); + if(width){ + newCanvas.width = width; + } + else{ + newCanvas.width = ctx.canvas.width; + } + + if(height){ + newCanvas.height = height; + } + else{ + newCanvas.height = ctx.canvas.height; + } + + return newCanvas; + } + + /** + * LEGACY: use render_objects() + */ + renderObjects(ctx, tempCanvas, layers, prepare, shouldSkip) { + this.render_objects(ctx, tempCanvas, layers, prepare, shouldSkip); + } + + /** + * Renders objects based on the provided layers + * @param {canvas.context} ctx - Main canvas context where it needs to be rendered + * @param {canvas} tempCanvas - A temporary canvas which is a copy of the original canvas, but will be used if there will be needed to isolate an effect from others + * @param {Object[]} layers - Array of layers + * @param {Function} prepare - An optional function to prepare temporary and main canvases before the render if needed + * @param {Function} shouldSkip - An optional boolean function for skipping those layers which are not needed to be rendered + */ + render_objects(ctx, tempCanvas, layers, prepare, shouldSkip) { + const tempCtx = tempCanvas.getContext("2d"); + // Prepare the temporary canvas if needed + prepare && prepare(); + + for (var i = layers.length - 1; i >= 0; i--) { + var layer = layers[i]; + const nextLayer = layers[i - 1]; + + // If the previous layer has clip masking effect and the current one is not the other end of the pair, + // then render the temporary canvas for clip masking on top of the current. + + // Skip the layer if not needed to be rendered + if (shouldSkip && shouldSkip(layer)) { + continue; + } + + // If the layer or next layer has clip masking effect (source-atop). + // If there are such layers, this will make sure that layers will be rendered + // in an isolated temporary canvas + if ( + layer.composition === "source-atop" || + (nextLayer && nextLayer.composition === "source-atop") + ) { + // Apply the effect in a isolated temporary canvas + tempCtx.globalAlpha = layer.opacity / 100; + tempCtx.globalCompositeOperation = layer.composition; + + // If the next layer has the clip masking effect then + // isolated the shadow filter from temporary canvas and keep that in the original canvas + if (nextLayer?.composition === "source-atop") { + // Render the layer + this.render_object(ctx, layer); + // Then remove the shadow (if it exists) from the render process in the temporary canvas + const filters = layer.filters.filter((filter) => { + return filter.name !== "shadow"; + }); + this.render_object(tempCtx, { + ...layer, + filters, + }); + } else { + // If we are in this condition, then it means this is the last layer of clipped layers pair. + // Render clipped layers on the temporary canvas + this.render_object(tempCtx, layer); + + // Render the clipped layers on top of the current canvas + ctx.restore(); + ctx.drawImage(tempCanvas, 0, 0); + + + // Prepare canvas to since we called restore + prepare && prepare(); + // Clear temporary canvas + tempCtx.globalCompositeOperation = null; + tempCtx.clearRect(0, 0, tempCanvas.width, tempCanvas.height); + } + } else { + ctx.globalAlpha = layer.opacity / 100; + ctx.globalCompositeOperation = layer.composition; + this.render_object(ctx, layer); + } + } + + } + + render_preview(layers) { + var w = this.Base_gui.GUI_preview.PREVIEW_SIZE.w; + var h = this.Base_gui.GUI_preview.PREVIEW_SIZE.h; + + this.ctx_preview.save(); + this.ctx_preview.clearRect(0, 0, w, h); + + const newCanvas = this.create_new_canvas(this.ctx_preview); + newCanvas.getContext("2d").scale(w / config.WIDTH, h / config.HEIGHT); + this.render_objects(this.ctx_preview, newCanvas, layers, () => { + this.ctx_preview.save(); + //prepare scale + this.ctx_preview.scale(w / config.WIDTH, h / config.HEIGHT); + }); + + this.ctx_preview.restore(); + this.Base_gui.GUI_preview.render_preview_active_zone(); + } + + /** + * export current layers to given canvas + * + * @param {canvas.context} ctx + * @param {object} object + * @param {boolean} is_preview + */ + render_object(ctx, object, is_preview) { + if (object.visible == false || object.type == null) return; + + this.pre_render_object(ctx, object); + + //example with canvas object - other types should overwrite this method + if (object.type == "image") { + //image - default behavior + ctx.save(); + + ctx.translate(object.x + object.width / 2, object.y + object.height / 2); + ctx.rotate((object.rotate * Math.PI) / 180); + // TODO - Not sure why the check should be with null, + // if nothing will break, then better to check if it's just truthy + ctx.drawImage( + object.link_canvas != null ? object.link_canvas : object.link, + -object.width / 2, + -object.height / 2, + object.width, + object.height + ); + + ctx.restore(); + } else { + //call render function from other module + var render_class = object.render_function[0]; + var render_function = object.render_function[1]; + if ( + typeof this.Base_gui.GUI_tools.tools_modules[render_class] != + "undefined" + ) { + this.Base_gui.GUI_tools.tools_modules[render_class].object[ + render_function + ](ctx, object, is_preview); + } else { + this.render_success = false; + console.log("Error: unknown layer type: " + object.type); + } + } + + this.after_render_object(ctx, object); + } + + /** + * Gets called before render_object starts it's job + * @param {canvas.context} ctx + * @param {object} object + */ + pre_render_object(ctx, object) { + //apply pre-filters + for (var i in object.filters) { + var filter = object.filters[i]; + if (filter.id == this.disabled_filter_id) { + continue; + } + + filter.name = filter.name.replace("drop-shadow", "shadow"); + + //find filter + var found = false; + for (var i in this.Base_gui.modules) { + if (i.indexOf("effects") == -1 || i.indexOf("abstract") > -1) continue; + + var filter_class = this.Base_gui.modules[i]; + var module_name = i.split("/").pop(); + if (module_name == filter.name) { + //found it + found = true; + filter_class.render_pre(ctx, filter, object); + } + } + if (found == false) { + this.render_success = false; + console.log("Error: can not find filter: " + filter.name); + } + } + } + + /** + * Gets called after when render_object finishes it's job + * @param {canvas.context} ctx + * @param {object} object + */ + after_render_object(ctx, object) { + //apply post-filters + for (var i in object.filters) { + var filter = object.filters[i]; + if (filter.id == this.disabled_filter_id) { + continue; + } + filter.name = filter.name.replace("drop-shadow", "shadow"); + + //find filter + var found = false; + for (var i in this.Base_gui.modules) { + if (i.indexOf("effects") == -1 || i.indexOf("abstract") > -1) continue; + + var filter_class = this.Base_gui.modules[i]; + var module_name = i.split("/").pop(); + if (module_name == filter.name) { + //found it + found = true; + filter_class.render_post(ctx, filter, object); + } + } + if (found == false) { + this.render_success = false; + console.log("Error: can not find filter: " + filter.name); + } + } + } + + /** + * creates new layer + * + * @param {array} settings + * @param {boolean} can_automate + */ + async insert(settings, can_automate = true) { + return app.State.do_action( + new app.Actions.Insert_layer_action(settings, can_automate) + ); + } + + /** + * autoresize layer, based on dimensions, up - always, if 1 layer - down. + * + * @param {int} width + * @param {int} height + * @param {int} layer_id + * @param {boolean} can_automate + */ + async autoresize(width, height, layer_id, can_automate = true) { + return app.State.do_action( + new app.Actions.Autoresize_canvas_action( + width, + height, + layer_id, + can_automate + ) + ); + } + + /** + * returns layer + * + * @param {int} id + * @returns {object} + */ + get_layer(id) { + if (id == undefined) { + id = config.layer.id; + } + for (var i in config.layers) { + if (config.layers[i].id == id) { + return config.layers[i]; + } + } + alertify.error("Error: can not find layer with id:" + id); + return null; + } + + /** + * removes layer + * + * @param {int} id + * @param {boolean} force - Force to delete first layer? + */ + async delete(id, force) { + return app.State.do_action(new app.Actions.Delete_layer_action(id, force)); + } + + /* + * removes all layers + */ + async reset_layers(auto_insert) { + return app.State.do_action( + new app.Actions.Reset_layers_action(auto_insert) + ); + } + + /** + * toggle layer visibility + * + * @param {int} id + */ + async toggle_visibility(id) { + return app.State.do_action( + new app.Actions.Toggle_layer_visibility_action(id) + ); + } + + /* + * renew layers HTML + */ + refresh_gui() { + this.Base_gui.GUI_layers.render_layers(); + } + + /** + * marks layer as selected, active + * + * @param {int} id + */ + async select(id) { + return app.State.do_action(new app.Actions.Select_layer_action(id)); + } + + /** + * change layer opacity + * + * @param {int} id + * @param {int} value 0-100 + */ + async set_opacity(id, value) { + value = parseInt(value); + if (value < 0 || value > 100) { + //reset + value = 100; + } + return app.State.do_action( + new app.Actions.Update_layer_action(id, { + opacity: value, + }) + ); + } + + /** + * clear layer data + * + * @param {int} id + */ + async layer_clear(id) { + return app.State.do_action(new app.Actions.Clear_layer_action(id)); + } + + /** + * move layer up or down + * + * @param {int} id + * @param {int} direction + */ + async move(id, direction) { + return app.State.do_action( + new app.Actions.Reorder_layer_action(id, direction) + ); + } + + /** + * clone and sort. + */ + get_sorted_layers() { + return config.layers.concat().sort( + //sort function + (a, b) => b.order - a.order + ); + } + + /** + * checks if layer empty + * + * @param {int} id + * @returns {Boolean} + */ + is_layer_empty(id) { + var link = this.get_layer(id); + + if ( + (link.width == 0 || link.width === null) && + (link.height == 0 || link.height === null) && + link.data == null + ) { + return true; + } + + return false; + } + + /** + * find next layer + * + * @param {int} id layer id + * @returns {layer|null} + */ + find_next(id) { + id = parseInt(id); + var link = this.get_layer(id); + var layers_sorted = this.get_sorted_layers(); + + var last = null; + for (var i = layers_sorted.length - 1; i >= 0; i--) { + var value = layers_sorted[i]; + + if (last != null && last.id == link.id) { + return value; + } + last = value; + } + + return null; + } + + /** + * find previous layer + * + * @param {int} id layer id + * @returns {layer|null} + */ + find_previous(id) { + id = parseInt(id); + var link = this.get_layer(id); + var layers_sorted = this.get_sorted_layers(); + + var last = null; + for (var i in layers_sorted) { + var value = layers_sorted[i]; + + if (last != null && last.id == link.id) { + return value; + } + last = value; + } + + return null; + } + + /** + * returns global position, for example if canvas is zoomed, it will convert relative mouse position to absolute + * at 100% zoom. + * + * @param {int} x + * @param {int} y + * @returns {object} keys: x, y + */ + get_world_coords(x, y) { + return zoomView.toWorld(x, y); + } + + /** + * register new live filter + * + * @param {int} layer_id + * @param {string} name + * @param {object} params + */ + add_filter(layer_id, name, params) { + return app.State.do_action( + new app.Actions.Add_layer_filter_action(layer_id, name, params) + ); + } + + /** + * delete live filter + * + * @param {int} layer_id + * @param {string} filter_id + */ + delete_filter(layer_id, filter_id) { + return app.State.do_action( + new app.Actions.Delete_layer_filter_action(layer_id, filter_id) + ); + } + + /** + * exports all layers to canvas for saving + * + * @param {canvas.context} ctx + * @param {int} layer_id Optional + * @param {boolean} is_preview Optional + */ + convert_layers_to_canvas(ctx, layer_id = null, is_preview = true) { + const newCanvas = this.create_new_canvas(ctx); + const layers_sorted = this.get_sorted_layers(); + this.render_objects(ctx, newCanvas, layers_sorted, ()=>{ + ctx.save(); + }, (value) => { + if (value.visible == false || value.type == null) { + return true; + } + if (layer_id != null && value.id != layer_id) { + return true; + } + }); + } + /** + * exports (active) layer to canvas for saving + * + * @param {int} layer_id or current layer by default + * @param {boolean} actual_area used for resized image. Default is false. + * @param {boolean} can_trim default is true + * @returns {canvas} + */ + convert_layer_to_canvas(layer_id, actual_area = false, can_trim) { + if (actual_area == null) actual_area = false; + if (layer_id == null) layer_id = config.layer.id; + var link = this.get_layer(layer_id); + var offset_x = 0; + var offset_y = 0; + + //create tmp canvas + var canvas = document.createElement("canvas"); + if (actual_area === true && link.type == "image") { + canvas.width = link.width_original; + canvas.height = link.height_original; + can_trim = false; + } else { + canvas.width = Math.max(link.width, config.WIDTH); + canvas.height = Math.max(link.height, config.HEIGHT); + } + + //add data + if (actual_area === true && link.type == "image") { + canvas.getContext("2d").drawImage(link.link, 0, 0); + } else { + this.render_object(canvas.getContext("2d"), link); + } + + //trim + if ((can_trim == true || can_trim == undefined) && link.type != null) { + var trim_info = this.Image_trim.get_trim_info(layer_id); + if ( + trim_info.left > 0 || + trim_info.top > 0 || + trim_info.right > 0 || + trim_info.bottom > 0 + ) { + offset_x = trim_info.left; + offset_y = trim_info.top; + + var w = canvas.width - trim_info.left - trim_info.right; + var h = canvas.height - trim_info.top - trim_info.bottom; + if (w > 1 && h > 1) { + this.Helper.change_canvas_size(canvas, w, h, offset_x, offset_y); + } + } + } + + canvas.dataset.x = offset_x; + canvas.dataset.y = offset_y; + + return canvas; + } + + /** + * updates layer image data + * + * @param {canvas} canvas + * @param {int} layer_id (optional) + */ + update_layer_image(canvas, layer_id) { + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas, layer_id) + ); + } + + /** + * returns canvas dimensions. + * + * @returns {object} + */ + get_dimensions() { + return { + width: config.WIDTH, + height: config.HEIGHT, + }; + } + + /** + * returns all layers + * + * @returns {array} + */ + get_layers() { + return config.layers; + } + + /** + * disabled filter by id + * + * @param filter_id + */ + disable_filter(filter_id) { + this.disabled_filter_id = filter_id; + } + + /** + * finds layer filter by filter ID + * + * @param filter_id + * @param filter_name + * @param layer_id + * @returns {object} + */ + find_filter_by_id(filter_id, filter_name, layer_id) { + if (typeof layer_id == "undefined") { + var layer = config.layer; + } else { + var layer = this.get_layer(layer_id); + } + + var filter = {}; + for (var i in layer.filters) { + if ( + layer.filters[i].name == filter_name && + layer.filters[i].id == filter_id + ) { + return layer.filters[i].params; + } + } + + return filter; + } +} + +export default Base_layers_class; diff --git a/paintplus/frontend/src/js/core/base-search.js b/paintplus/frontend/src/js/core/base-search.js new file mode 100644 index 0000000..56b8a78 --- /dev/null +++ b/paintplus/frontend/src/js/core/base-search.js @@ -0,0 +1,166 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../config.js'; +import Dialog_class from './../libs/popup.js'; +import Base_gui_class from './base-gui.js'; +const fuzzysort = require('fuzzysort'); + +var instance = null; + +class Base_search_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_gui = new Base_gui_class(); + this.db = null; + + this.events(); + } + + events() { + document.addEventListener('keydown', (event) => { + if (this.POP.get_active_instances() > 0) { + return; + } + + var code = event.key; + if (code == "F3" || ( (event.ctrlKey == true || event.metaKey) && code == "f")) { + //open + this.search(); + event.preventDefault(); + } + }, false); + + document.addEventListener('input', (event) => { + if(document.querySelector('#pop_data_search') == null){ + return; + } + + var node = document.querySelector('#global_search_results'); + node.innerHTML = ''; + + var query = event.target.value; + if(query == ''){ + return; + } + + let results = fuzzysort.go(query, this.db, { + keys: ['title'], + limit: 10, + threshold: -50000, + }); + + //show + for(var i = 0; i < results.length; i++) { + var item = results[i]; + + var className = "search-result n" + (i+1); + if(i == 0){ + className += " active"; + } + + node.innerHTML += "
" + + fuzzysort.highlight(item[0]) + "
"; + } + }, false); + + //allow to select with arrow keys + document.addEventListener('keydown', function (e) { + if(document.querySelector('#global_search_results') == null + || document.querySelector('.search-result') == null){ + return; + } + var k = e.key; + + if (k == "ArrowUp") { + var target = document.querySelector('.search-result.active'); + var index = Array.from(target.parentNode.children).indexOf(target); + if(index > 0){ + index--; + } + target.classList.remove('active'); + var target2 =document.querySelector('#global_search_results').childNodes[index]; + target2.classList.add('active'); + e.preventDefault(); + } + else if (k == "ArrowDown") { + var target = document.querySelector('.search-result.active'); + var index = Array.from(target.parentNode.children).indexOf(target); + var total = target.parentNode.childElementCount; + if(index < total - 1){ + index++; + } + target.classList.remove('active'); + var target2 = document.querySelector('#global_search_results').childNodes[index]; + target2.classList.add('active'); + e.preventDefault(); + } + + }, false); + } + + search() { + var _this = this; + + //init DB + if(this.db === null) { + this.db = Object.keys(this.Base_gui.modules); + for(var i in this.db){ + this.db[i] = { + key: this.db[i], + title: this.db[i].replace(/_/i, ' '), + }; + } + } + + var settings = { + title: 'Search', + params: [ + {name: "search", title: "Search:", value: ""}, + ], + on_load: function (params, popup) { + var node = document.createElement("div"); + node.id = 'global_search_results'; + node.innerHTML = ''; + popup.el.querySelector('.dialog_content').appendChild(node); + }, + on_finish: function (params) { + //execute + var target = document.querySelector('.search-result.active'); + if(target){ + //execute + var key = target.dataset.key; + var class_object = this.Base_gui.modules[key]; + var function_name = _this.get_function_from_path(key); + + _this.POP.hide(); + class_object[function_name](); + } + }, + }; + this.POP.show(settings); + + //on input change + document.getElementById("pop_data_search").select(); + } + + get_function_from_path(path){ + var parts = path.split("/"); + var result = parts[parts.length - 1]; + result = result.replace(/-/, '_'); + + return result; + } + +} + +export default Base_search_class; diff --git a/paintplus/frontend/src/js/core/base-selection.js b/paintplus/frontend/src/js/core/base-selection.js new file mode 100644 index 0000000..efe9e39 --- /dev/null +++ b/paintplus/frontend/src/js/core/base-selection.js @@ -0,0 +1,558 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../config.js'; + +var instance = null; +var settings_all = []; + +const handle_size = 12; + +const DRAG_TYPE_TOP = 1; +const DRAG_TYPE_BOTTOM = 2; +const DRAG_TYPE_LEFT = 4; +const DRAG_TYPE_RIGHT = 8; + +/** + * Selection class - draws rectangular selection on canvas, can be resized. + */ +class Base_selection_class { + + /** + * settings: + * - enable_background + * - enable_borders + * - enable_controls + * - enable_rotation + * - enable_move + * - keep_ratio + * + * @param {ctx} ctx + * @param {object} settings + * @param {string|null} key + */ + constructor(ctx, settings, key = null) { + if (key != null) { + settings_all[key] = settings; + } + + //singleton + if (instance) { + return instance; + } + instance = this; + + this.ctx = ctx; + this.mouse_lock = null; + this.selected_obj_positions = {}; + this.selected_obj_rotate_position = {}; + this.selected_object_drag_type = null; + this.click_details = {}; + this.is_touch = false; + // True if dragging from inside canvas area + this.is_drag = false; + this.current_angle = null; + + this.events(); + } + + events() { + document.addEventListener('mousedown', (e) => { + this.is_drag = false; + if(this.is_touch == true) + return; + if (!e.target.closest('#main_wrapper')) + return; + this.is_drag = true; + this.selected_object_actions(e); + }); + document.addEventListener('mousemove', (e) => { + if(this.is_touch == true) + return; + this.selected_object_actions(e); + }); + document.addEventListener('mouseup', (e) => { + if(this.is_touch == true) + return; + this.selected_object_actions(e); + }); + + // touch + document.addEventListener('touchstart', (event) => { + this.is_drag = false; + this.is_touch = true; + if (!event.target.closest('#main_wrapper')) + return; + this.is_drag = true; + this.selected_object_actions(event); + }); + document.addEventListener('touchmove', (event) => { + this.selected_object_actions(event); + }, {passive: false}); + document.addEventListener('touchend', (event) => { + this.selected_object_actions(event); + }); + } + + set_selection(x, y, width, height) { + var settings = this.find_settings(); + + if (x != null) + settings.data.x = x; + if (y != null) + settings.data.y = y; + if (width != null) + settings.data.width = width; + if (height != null) + settings.data.height = height; + config.need_render = true; + } + + reset_selection() { + var settings = this.find_settings(); + + settings.data = { + x: null, + y: null, + width: null, + height: null, + }; + config.need_render = true; + } + + get_selection() { + var settings = this.find_settings(); + + return settings.data; + } + + find_settings() { + var current_key = config.TOOL.name; + var settings = null; + + for (var i in settings_all) { + if (i == current_key) + settings = settings_all[i]; + } + + //default + if (settings === null) { + settings = settings_all['main']; + } + + //find data + settings.data = (settings.data_function).call(); + + return settings; + } + + calcRotateDistanceFromX(layerW) { + const block_size = handle_size / config.ZOOM; + + return Math.max( + Math.min(layerW * 0.9, Math.abs(layerW - 2 * block_size)), + layerW / 2 - block_size / 2 + ); + } + /** + * marks object as selected, and draws corners + */ + draw_selection() { + var settings = this.find_settings(); + var data = settings.data; + + if (settings.data === null || settings.data.status == 'draft' + || (settings.data.hide_selection_if_active === true && settings.data.type == config.TOOL.name)) { + return; + } + + var x = settings.data.x; + var y = settings.data.y; + var w = settings.data.width; + var h = settings.data.height; + + if (x == null || y == null || w == null || h == null) { + //not supported + return; + } + + var block_size_default = handle_size / config.ZOOM; + + if (config.ZOOM != 1) { + x = Math.round(x); + y = Math.round(y); + w = Math.round(w); + h = Math.round(h); + } + var block_size = block_size_default; + var corner_offset = (block_size / 2.4); + var middle_offset = (block_size / 1.9); + + this.ctx.save(); + this.ctx.globalAlpha = 1; + let isRotated = false; + if (data.rotate != null && data.rotate != 0) { + //rotate + isRotated = true; + this.ctx.translate(data.x + data.width / 2, data.y + data.height / 2); + this.ctx.rotate(data.rotate * Math.PI / 180); + x = Math.round(-data.width / 2); + y = Math.round(-data.height / 2); + } + + //fill + if (settings.enable_background == true) { + this.ctx.fillStyle = "rgba(0, 255, 0, 0.3)"; + this.ctx.fillRect(x, y, w, h); + } + + const wholeLineWidth = 2 / config.ZOOM; + const halfLineWidth = wholeLineWidth / 2; + + //borders + if (settings.enable_borders == true && (x != 0 || y != 0 || w != config.WIDTH || h != config.HEIGHT)) { + this.ctx.lineWidth = wholeLineWidth; + this.ctx.strokeStyle = 'rgb(255, 255, 255)'; + this.ctx.strokeRect(x - halfLineWidth, y - halfLineWidth, w + wholeLineWidth, h + wholeLineWidth); + this.ctx.lineWidth = halfLineWidth; + this.ctx.strokeStyle = 'rgb(0, 0, 0)'; + this.ctx.strokeRect(x - wholeLineWidth, y - wholeLineWidth, w + (wholeLineWidth * 2), h + (wholeLineWidth * 2)); + } + + //show crop lines + if(settings.crop_lines === true){ + + for(var part = 1; part < 3; part++) { + this.ctx.lineWidth = wholeLineWidth; + this.ctx.strokeStyle = 'rgb(255, 255, 255)'; + this.ctx.beginPath(); + this.ctx.moveTo(x + w / 3 * part - halfLineWidth, y); + this.ctx.lineTo(x + w / 3 * part - halfLineWidth, y + h); + this.ctx.stroke(); + + this.ctx.lineWidth = halfLineWidth; + this.ctx.strokeStyle = 'rgb(0, 0, 0)'; + this.ctx.beginPath(); + this.ctx.moveTo(x + w / 3 * part - halfLineWidth, y); + this.ctx.lineTo(x + w / 3 * part - halfLineWidth, y + h); + this.ctx.stroke(); + } + + for(var part = 1; part < 3; part++) { + this.ctx.lineWidth = wholeLineWidth; + this.ctx.strokeStyle = 'rgb(255, 255, 255)'; + this.ctx.beginPath(); + this.ctx.moveTo(x, y + h / 3 * part - halfLineWidth); + this.ctx.lineTo(x + w, y + h / 3 * part - halfLineWidth); + this.ctx.stroke(); + + this.ctx.lineWidth = halfLineWidth; + this.ctx.strokeStyle = 'rgb(0, 0, 0)'; + this.ctx.beginPath(); + this.ctx.moveTo(x, y + h / 3 * part - halfLineWidth); + this.ctx.lineTo(x + w, y + h / 3 * part - halfLineWidth); + this.ctx.stroke(); + } + } + + const hitsLeftEdge = isRotated ? false : x < handle_size; + const hitsTopEdge = isRotated ? false : y < handle_size; + const hitsRightEdge = isRotated ? false : x + w > config.WIDTH - handle_size; + const hitsBottomEdge = isRotated ? false : y + h > config.HEIGHT - handle_size; + + //draw corners + var corner = (x, y, dx, dy, drag_type, cursor) => { + var angle = 0; + if (settings.data.rotate != null && settings.data.rotate != 0) { + angle = settings.data.rotate; + } + + if (settings.enable_controls == false || angle != 0) { + this.ctx.strokeStyle = "rgba(0, 0, 0, 0.4)"; + this.ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + } + else { + this.ctx.strokeStyle = "#000000"; + this.ctx.fillStyle = "#ffffff"; + } + this.ctx.lineWidth = wholeLineWidth; + + //create path + const circle = new Path2D(); + circle.arc(x + dx * block_size, y + dy * block_size, block_size / 2, 0, 2 * Math.PI); + + //draw + this.ctx.fill(circle); + this.ctx.stroke(circle); + + //register position + this.selected_obj_positions[drag_type] = { + cursor: cursor, + path: circle, + }; + }; + + //draw rotation + var draw_rotation = () => { + var settings = this.find_settings(); + + if (settings.data === null + || settings.data.status == 'draft' + || settings.data.rotate === null + || (settings.data.hide_selection_if_active === true && settings.data.type == config.TOOL.name)) { + return; + } + + var r_x = x + this.calcRotateDistanceFromX(w) + corner_offset + wholeLineWidth; + var r_y = y - corner_offset - wholeLineWidth; + var r_dx = hitsRightEdge ? -0.5 : 0; + var r_dy = hitsTopEdge ? 0.5 : 0; + + this.ctx.strokeStyle = "#000000"; + this.ctx.fillStyle = "#d0d62a"; + this.ctx.lineWidth = wholeLineWidth; + + //create path + const circle = new Path2D(); + circle.arc(r_x + r_dx * block_size, r_y + r_dy * block_size, block_size / 2, 0, 2 * Math.PI); + + //draw + this.ctx.fill(circle); + this.ctx.stroke(circle); + + //register position + this.selected_obj_rotate_position = { + cursor: "pointer", + path: circle, + }; + + }; + if (settings.enable_rotation == true) { + draw_rotation(); + } + + if (settings.enable_controls == true) { + corner(x - corner_offset - wholeLineWidth, y - corner_offset - wholeLineWidth, hitsLeftEdge ? 0.5 : 0, hitsTopEdge ? 0.5 : 0, DRAG_TYPE_LEFT | DRAG_TYPE_TOP, 'nwse-resize'); + corner(x + w + corner_offset + wholeLineWidth, y - corner_offset - wholeLineWidth, hitsRightEdge ? -0.5 : 0, hitsTopEdge ? 0.5 : 0, DRAG_TYPE_RIGHT | DRAG_TYPE_TOP, 'nesw-resize'); + corner(x - corner_offset - wholeLineWidth, y + h + corner_offset + wholeLineWidth, hitsLeftEdge ? 0.5 : 0, hitsBottomEdge ? -0.5 : 0, DRAG_TYPE_LEFT | DRAG_TYPE_BOTTOM, 'nesw-resize'); + corner(x + w + corner_offset + wholeLineWidth, y + h + corner_offset + wholeLineWidth, hitsRightEdge ? -0.5 : 0, hitsBottomEdge ? -0.5 : 0, DRAG_TYPE_RIGHT | DRAG_TYPE_BOTTOM, 'nwse-resize'); + } + + if (settings.enable_controls == true) { + //draw centers + if (Math.abs(w) > block_size * 5) { + corner(x + w / 2, y - middle_offset - wholeLineWidth, 0, hitsTopEdge ? 0.5 : 0, DRAG_TYPE_TOP, 'ns-resize'); + corner(x + w / 2, y + h + middle_offset + wholeLineWidth, 0, hitsBottomEdge ? -0.5 : 0, DRAG_TYPE_BOTTOM, 'ns-resize'); + } + if (Math.abs(h) > block_size * 5) { + corner(x - middle_offset - wholeLineWidth, y + h / 2, hitsLeftEdge ? 0.5 : 0, 0, DRAG_TYPE_LEFT, 'ew-resize'); + corner(x + w + middle_offset + wholeLineWidth, y + h / 2, hitsRightEdge ? -0.5 : 0, 0, DRAG_TYPE_RIGHT, 'ew-resize'); + } + } + + //restore + this.ctx.restore(); + } + + selected_object_actions(e) { + var settings = this.find_settings(); + var data = settings.data; + + if(data == null){ + return; + } + + this.ctx.save(); + if (data.rotate != null && data.rotate != 0) { + this.ctx.translate(data.x + data.width / 2, data.y + data.height / 2); + this.ctx.rotate(data.rotate * Math.PI / 180); + } + + var x = settings.data.x; + var y = settings.data.y; + var w = settings.data.width; + var h = settings.data.height; + + //simplify checks + var event_type = e.type; + if(event_type == 'touchstart') event_type = 'mousedown'; + if(event_type == 'touchmove') event_type = 'mousemove'; + if(event_type == 'touchend') event_type = 'mouseup'; + + if (!this.is_drag && ['mousedown', 'mouseup'].includes(event_type)) + return; + + const mainWrapper = document.getElementById('main_wrapper'); + const defaultCursor = config.TOOL && config.TOOL.name === 'text' ? 'text' : 'default'; + if (mainWrapper.style.cursor != defaultCursor) { + mainWrapper.style.cursor = defaultCursor; + } + if (event_type == 'mousedown' && config.mouse.valid == false || settings.enable_controls == false) { + return; + } + + var mouse = config.mouse; + const drag_type = this.selected_object_drag_type; + + if(event_type == 'mousedown' && settings.data !== null){ + this.click_details = { + x: settings.data.x, + y: settings.data.y, + width: settings.data.width, + height: settings.data.height, + }; + this.current_angle = null; + } + if (event_type == 'mousemove' && this.mouse_lock == 'selected_object_actions' && this.is_drag) { + + const allowNegativeDimensions = settings.data.render_function + && ['line', 'arrow', 'gradient'].includes(settings.data.render_function[0]); + + mainWrapper.style.cursor = "pointer"; + + var is_ctrl = false; + if (e.ctrlKey == true || e.metaKey) { + is_ctrl = true; + } + + const is_drag_type_left = Math.floor(drag_type / DRAG_TYPE_LEFT) % 2 === 1; + const is_drag_type_right = Math.floor(drag_type / DRAG_TYPE_RIGHT) % 2 === 1; + const is_drag_type_top = Math.floor(drag_type / DRAG_TYPE_TOP) % 2 === 1; + const is_drag_type_bottom = Math.floor(drag_type / DRAG_TYPE_BOTTOM) % 2 === 1; + + if(is_drag_type_left && is_drag_type_top) mainWrapper.style.cursor = "nwse-resize"; + else if(is_drag_type_top && is_drag_type_right) mainWrapper.style.cursor = "nesw-resize"; + else if(is_drag_type_right && is_drag_type_bottom) mainWrapper.style.cursor = "nwse-resize"; + else if(is_drag_type_bottom && is_drag_type_left) mainWrapper.style.cursor = "nesw-resize"; + else if(is_drag_type_top) mainWrapper.style.cursor = "ns-resize"; + else if(is_drag_type_right) mainWrapper.style.cursor = "ew-resize"; + else if(is_drag_type_bottom) mainWrapper.style.cursor = "ns-resize"; + else if(is_drag_type_left) mainWrapper.style.cursor = "ew-resize"; + + if(drag_type == 'rotate'){ + //rotate + var dx = x + this.calcRotateDistanceFromX(w) - (x + w / 2); + var dy = h / 2; + var original_angle = Math.atan2(dy, dx) / Math.PI * 180; //compensate rotation icon angle + + var dx = mouse.x - (x + w / 2); + var dy = mouse.y - (y + h / 2); + var angle = Math.atan2(dy, dx) / Math.PI * 180 + original_angle; + + //settings.data.rotate = angle; + this.current_angle = angle; + + config.need_render = true; + } + else if (e.buttons == 1 || typeof e.buttons == "undefined") { + // Do transformations + var dx = Math.round(mouse.x - mouse.click_x); + var dy = Math.round(mouse.y - mouse.click_y); + var width = this.click_details.width + dx; + var height = this.click_details.height + dy; + if (is_drag_type_top) + height = this.click_details.height - dy; + if (is_drag_type_left) + width = this.click_details.width - dx; + + // Keep ratio - (if drag_type power of 2, only dragging on single axis) + if (drag_type && (drag_type & (drag_type - 1)) !== 0 && (settings.keep_ratio == true && is_ctrl == false) + || (settings.keep_ratio !== true && is_ctrl == true)){ + var ratio = this.click_details.width / this.click_details.height; + var width_new = Math.round(height * ratio); + var height_new = Math.round(width / ratio); + + if (Math.abs(width * 100 / width_new) > Math.abs(height * 100 / height_new)) { + height = height_new; + } + else { + width = width_new; + } + } + + // Set values + settings.data.x = this.click_details.x; + settings.data.y = this.click_details.y; + if (is_drag_type_top) + settings.data.y = this.click_details.y - (height - this.click_details.height); + if (is_drag_type_left) + settings.data.x = this.click_details.x - (width - this.click_details.width); + if (is_drag_type_left || is_drag_type_right) + settings.data.width = width; + if (is_drag_type_top || is_drag_type_bottom) + settings.data.height = height; + + // Don't allow negative width/height on most layers + if (!allowNegativeDimensions) { + if (settings.data.width <= 0) { + settings.data.width = Math.abs(settings.data.width); + if (is_drag_type_left) { + settings.data.x -= settings.data.width; + } else { + settings.data.x = this.click_details.x - settings.data.width; + } + } + if (settings.data.height <= 0) { + settings.data.height = Math.abs(settings.data.height); + if (is_drag_type_top) { + settings.data.y -= settings.data.height; + } else { + settings.data.y = this.click_details.y - settings.data.height; + } + } + } + config.need_render = true; + } + return; + } + if (event_type == 'mouseup' && this.mouse_lock == 'selected_object_actions') { + //reset + this.mouse_lock = null; + } + + if (!this.mouse_lock) { + //set mouse move cursor + if(settings.enable_move && mouse.x > x && mouse.x < x + w && mouse.y > y && mouse.y < y + h){ + mainWrapper.style.cursor = "move"; + } + + for (let current_drag_type in this.selected_obj_positions) { + const position = this.selected_obj_positions[current_drag_type]; + if (position.path && this.ctx.isPointInPath(position.path, mouse.x, mouse.y)) { + // match + if (event_type == 'mousedown') { + if (e.buttons == 1 || typeof e.buttons == "undefined") { + this.mouse_lock = 'selected_object_actions'; + this.selected_object_drag_type = current_drag_type; + } + } + if (event_type == 'mousemove') { + mainWrapper.style.cursor = position.cursor; + } + } + } + + //rotate? + const position = this.selected_obj_rotate_position; + if (position.path && this.ctx.isPointInPath(position.path, mouse.x, mouse.y)) { + //match + if (event_type == 'mousedown') { + if (e.buttons == 1 || typeof e.buttons == "undefined") { + this.mouse_lock = 'selected_object_actions'; + this.selected_object_drag_type = "rotate"; + } + } + if (event_type == 'mousemove') { + mainWrapper.style.cursor = position.cursor; + } + } + + this.ctx.restore(); + } + } + +} + +export default Base_selection_class; diff --git a/paintplus/frontend/src/js/core/base-state.js b/paintplus/frontend/src/js/core/base-state.js new file mode 100644 index 0000000..b9852f8 --- /dev/null +++ b/paintplus/frontend/src/js/core/base-state.js @@ -0,0 +1,222 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../config.js'; +import Base_layers_class from './base-layers.js'; +import Base_gui_class from './base-gui.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import app from '../app.js'; + +var instance = null; + +/** + * Undo state class. Supports multiple levels undo. + */ +class Base_state_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.layers_archive = []; + this.levels = 3; + this.levels_optimal = 3; + this.enabled = true; + this.action_history = []; + this.action_history_index = 0; + this.action_history_max = 50; + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + const key = (event.key || '').toLowerCase(); + if (this.Helper.is_input(event.target)) + return; + + if (key == "z" && (event.ctrlKey == true || event.metaKey)) { + // Undo + this.undo(); + event.preventDefault(); + } + if (key == "y" && (event.ctrlKey == true || event.metaKey)) { + // Redo + this.redo(); + event.preventDefault(); + } + }, false); + } + + async do_action(action, options = {}) { + let error_during_free = false; + try { + await action.do(); + } catch (error) { + // Action aborted. This is usually expected behavior as actions throw errors if they shouldn't run. + return { status: 'aborted', reason: error }; + } + // Remove all redo actions from history + if (this.action_history_index < this.action_history.length) { + const freed_actions = this.action_history.slice(this.action_history_index, this.action_history.length).reverse(); + this.action_history = this.action_history.slice(0, this.action_history_index); + for (let freed_action of freed_actions) { + try { + await freed_action.free(); + } catch (error) { + error_during_free = true; + } + } + } + // Add the new action to history + const last_action = this.action_history[this.action_history.length - 1]; + if (options.merge_with_history && last_action) { + if (typeof options.merge_with_history === 'string') { + options.merge_with_history = [options.merge_with_history]; + } + if (options.merge_with_history.includes(last_action.action_id)) { + this.action_history[this.action_history.length - 1] = new app.Actions.Bundle_action( + last_action.action_id, + last_action.action_description, + [last_action, action] + ); + } + } else { + this.action_history.push(action); + if (this.action_history.length > this.action_history_max) { + let action_to_free = this.action_history.shift(); + try { + await action_to_free.free(); + } catch (error) { + error_during_free = true; + } + } else { + this.action_history_index++; + } + } + + // Chrome arbitrary method to determine memory usage, but most people use Chrome so... + if (window.performance && window.performance.memory) { + if (window.performance.memory.usedJSHeapSize > window.performance.memory.jsHeapSizeLimit * 0.8) { + this.free(window.performance.memory.jsHeapSizeLimit * 0.2); + } + } + + if (error_during_free) { + alertify.error('A problem occurred while removing undo history. It\'s suggested you save your work and refresh the page in order to free up memory.'); + } + return { status: 'completed' }; + } + + can_redo() { + return this.action_history_index < this.action_history.length; + } + + can_undo() { + return this.action_history_index > 0; + } + + async redo_action() { + if (this.can_redo()) { + const action = this.action_history[this.action_history_index]; + await action.do(); + this.action_history_index++; + } else { + alertify.success('There\'s nothing to redo', 3); + } + } + + async undo_action() { + if (this.can_undo()) { + this.action_history_index--; + await this.action_history[this.action_history_index].undo(); + } else { + alertify.success('There\'s nothing to undo', 3); + } + } + + async scrap_last_action() { + if (this.can_undo()) { + await this.undo_action(); + this.action_history.pop(); + } + } + + // Frees history actions up to the specified memory & database size. Starts with undo history, then moves to redo history. + async free(memory_size = 0, database_size = 0) { + let total_memory_freed = 0; + let total_database_freed = 0; + let has_error = false; + let free_complete = false; + while (this.action_history_index > 0) { + let action = this.action_history.shift(); + total_memory_freed += action.memory_estimate; + total_database_freed += action.database_estimate; + try { + await action.free(); + } catch (error) { + has_error = true; + } + if (total_memory_freed >= memory_size && total_database_freed >= database_size) { + free_complete = true; + break; + } + this.action_history_index--; + } + if (!free_complete) { + for (let i = this.action_history.length - 1; i >= 0; i--) { + let action = this.action_history[i]; + total_memory_freed += action.memory_estimate; + total_database_freed += action.database_estimate; + try { + await action.free(); + } catch (error) { + has_error = true; + } + if (total_memory_freed >= memory_size && total_database_freed >= database_size) { + free_complete = true; + break; + } + } + } + if (has_error) { + alertify.error('A problem occurred while removing undo history. It\'s suggested you save your work and refresh the page in order to free up memory.'); + } + return { + total_memory_freed, + total_database_freed + } + } + + save() { + const message = 'window.State.save() is removed. Use State.do_action() to manage undo history instead.'; + console.warn(message); + alertify.error(message); + } + + /** + * supports multiple levels undo system + */ + undo() { + this.undo_action(); + } + + /** + * supports multiple levels redo system + */ + redo() { + this.redo_action(); + } + +} + +export default Base_state_class; diff --git a/paintplus/frontend/src/js/core/base-tools.js b/paintplus/frontend/src/js/core/base-tools.js new file mode 100644 index 0000000..096f5af --- /dev/null +++ b/paintplus/frontend/src/js/core/base-tools.js @@ -0,0 +1,734 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../config.js'; +import Base_layers_class from './base-layers.js'; +import Base_gui_class from './base-gui.js'; +import app from "../app"; +import Helper_class from "../libs/helpers"; + +/** + * Base tools class, can be used for extending on tools like brush, provides various helping methods. + */ +class Base_tools_class { + + constructor(save_mouse) { + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.is_drag = false; + this.mouse_last_click_pos = [false, false]; + this.mouse_click_pos = [false, false]; + this.mouse_move_last = [false, false]; + this.mouse_valid = false; + this.mouse_click_valid = false; + this.speed_average = 0; + this.save_mouse = save_mouse; + this.is_touch = false; + this.shape_mouse_click = {x: null, y: null}; + + this.prepare(); + + if (this.save_mouse == true) { + this.events(); + } + } + + dragStart(event) { + var _this = this; + + var mouse = _this.get_mouse_info(event, true); + _this.mouse_click_pos[0] = mouse.x; + _this.mouse_click_pos[1] = mouse.y; + + //update + _this.set_mouse_info(event); + + _this.is_drag = true; + _this.speed_average = 0; + + var mouse = _this.get_mouse_info(event, true); + _this.mouse_last_click_pos[0] = mouse.x; + _this.mouse_last_click_pos[1] = mouse.y; + } + + dragMove(event) { + var _this = this; + _this.set_mouse_info(event); + + _this.speed_average = _this.calc_average_mouse_speed(event); + } + + dragEnd(event) { + var _this = this; + _this.is_drag = false; + _this.set_mouse_info(event); + } + + events() { + var _this = this; + + //collect mouse info + document.addEventListener('mousedown', function (event) { + if(_this.is_touch == true) + return; + + _this.dragStart(event); + }); + document.addEventListener('mousemove', function (event) { + if(_this.is_touch == true) + return; + + _this.dragMove(event); + }); + document.addEventListener('mouseup', function (event) { + if(_this.is_touch == true) + return; + + _this.dragEnd(event); + }); + + // collect touch info + document.addEventListener('touchstart', function (event) { + _this.is_touch = true; + _this.dragStart(event); + }); + document.addEventListener('touchmove', function (event) { + _this.dragMove(event); + if (event.target.id === "canvas_minipaint" && !$('.scroll').has($(event.target)).length) + event.preventDefault(); + }, {passive: false}); + document.addEventListener('touchend', function (event) { + _this.dragEnd(event); + }); + + //on resize + window.addEventListener('resize', function (event) { + _this.prepare(); + }); + } + + /** + * do preparation + */ + prepare() { + this.is_drag = config.mouse.is_drag; + } + + set_mouse_info(event) { + if (this.save_mouse !== true) { + //not main + return false; + } + + var eventType = event.type; + + if (event.target.id != 'canvas_minipaint' && event.target.id != 'main_wrapper') { + //outside canvas + this.mouse_valid = false; + } + else { + this.mouse_valid = true; + } + + if (eventType === 'mousedown' || eventType === 'touchstart') { + if ((event.target.id != 'canvas_minipaint' && event.target.id != 'main_wrapper') || (event.which != 1 && eventType !== 'touchstart')) { + this.mouse_click_valid = false; + } + else { + this.mouse_click_valid = true; + } + this.mouse_valid = true; + } + + if (event.changedTouches) { + //using touch events + event = event.changedTouches[0]; + } + + var mouse_coords = this.get_mouse_coordinates_from_event(event); + var mouse_x = mouse_coords.x; + var mouse_y = mouse_coords.y; + + var start_pos = this.Base_layers.get_world_coords(0, 0); + var x_rel = mouse_x - start_pos.x; + var y_rel = mouse_y - start_pos.y; + + //save + config.mouse = { + x: mouse_x, + y: mouse_y, + x_rel: x_rel, + y_rel: y_rel, + last_click_x: this.mouse_last_click_pos[0], //last click + last_click_y: this.mouse_last_click_pos[1], //last click + click_x: this.mouse_click_pos[0], + click_y: this.mouse_click_pos[1], + last_x: this.mouse_move_last[0], + last_y: this.mouse_move_last[1], + valid: this.mouse_valid, + click_valid: this.mouse_click_valid, + is_drag: this.is_drag, + speed_average: this.speed_average, + }; + + if (eventType === 'mousemove' || eventType === 'touchmove') { + //save last pos + this.mouse_move_last[0] = mouse_x; + this.mouse_move_last[1] = mouse_y; + } + } + + get_mouse_coordinates_from_event(event){ + var mouse_x = event.pageX - this.Base_gui.canvas_offset.x; + var mouse_y = event.pageY - this.Base_gui.canvas_offset.y; + + //adapt coords to ZOOM + var global_pos = this.Base_layers.get_world_coords(mouse_x, mouse_y); + mouse_x = global_pos.x; + mouse_y = global_pos.y; + + return { + x: mouse_x, + y: mouse_y, + }; + } + + get_mouse_info(event) { + if(typeof event != "undefined" && typeof mouse.x == "undefined"){ + //mouse not set yet - set it now... + this.set_mouse_info(event); + } + return config.mouse; + } + + calc_average_mouse_speed(event) { + if (this.is_drag == false) + return null; + + //calc average speed + var avg_speed_max = 30; + var avg_speed_changing_power = 2; + var mouse = this.get_mouse_info(event, true); + + var dx = Math.abs(mouse.x - mouse.last_x); + var dy = Math.abs(mouse.y - mouse.last_y); + var delta = Math.sqrt(dx * dx + dy * dy); + var mouse_average_speed = this.speed_average; + if (delta > avg_speed_max / 2) { + mouse_average_speed += avg_speed_changing_power; + } + else { + mouse_average_speed -= avg_speed_changing_power; + } + mouse_average_speed = Math.max(0, mouse_average_speed); //min + mouse_average_speed = Math.min(avg_speed_max, mouse_average_speed); //max + + return mouse_average_speed; + } + + get_params_hash() { + var data = [ + this.getParams(), + config.COLOR, + config.ALPHA, + ]; + return JSON.stringify(data); + } + + clone(object) { + return JSON.parse(JSON.stringify(object)); + } + + /** + * customized mouse cursor + * + * @param {int} x + * @param {int} y + * @param {int} size + * @param {string} type circle, rect + */ + show_mouse_cursor(x, y, size, type) { + + //fix coordinates, because of scroll + var start_pos = this.Base_layers.get_world_coords(0, 0); + x = x - start_pos.x; + y = y - start_pos.y; + + var element = document.getElementById('mouse'); + size = size * config.ZOOM; + x = x * config.ZOOM; + y = y * config.ZOOM; + + if (size < 5) { + //too small + element.className = ''; + return; + } + + element.style.width = size + 'px'; + element.style.height = size + 'px'; + + element.style.left = x - Math.ceil(size / 2) + 'px'; + element.style.top = y - Math.ceil(size / 2) + 'px'; + + //add style + element.className = ''; + element.classList.add(type); + } + + getParams() { + const params = {}; + // Number inputs return the .value if defined as objects. + for (let attributeName in config.TOOL.attributes) { + const attribute = config.TOOL.attributes[attributeName]; + if (!isNaN(attribute.value) && attribute.value != null) { + if (typeof attribute.value === 'string') { + params[attributeName] = attribute; + } else { + params[attributeName] = attribute.value; + } + } else { + params[attributeName] = attribute; + } + } + return params; + } + + adaptSize(value, type = "width") { + var response; + if (config.layer.width_original == null) { + return value; + } + + if (type === "width") { + response = value / (config.layer.width / config.layer.width_original); + } + else { + response = value / (config.layer.height / config.layer.height_original); + } + + return response; + } + + draw_shape(ctx, x, y, width, height, coords, is_demo) { + if(is_demo !== false) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + } + ctx.lineJoin = "round"; + + ctx.beginPath(); + for(var i in coords){ + if(coords[i] === null){ + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + continue; + } + + //coords in 100x100 box + var pos_x = x + coords[i][0] * width / 100; + var pos_y = y + coords[i][1] * height / 100; + + if(i == '0') + ctx.moveTo(pos_x, pos_y); + else + ctx.lineTo(pos_x, pos_y); + } + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + } + + default_events(){ + var _this = this; + + //mouse events + document.addEventListener('mousedown', function (event) { + _this.default_dragStart(event); + }); + document.addEventListener('mousemove', function (event) { + _this.default_dragMove(event); + }); + document.addEventListener('mouseup', function (event) { + _this.default_dragEnd(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + _this.default_dragStart(event); + }); + document.addEventListener('touchmove', function (event) { + _this.default_dragMove(event); + }); + document.addEventListener('touchend', function (event) { + _this.default_dragEnd(event); + }); + } + + default_dragStart(event) { + if (config.TOOL.name != this.name) + return; + this.mousedown(event); + } + + default_dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + } + + default_dragEnd(event) { + if (config.TOOL.name != this.name) + return; + this.mouseup(event); + } + + shape_mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + this.shape_mouse_click.x = mouse_x; + this.shape_mouse_click.y = mouse_y; + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: Math.round(mouse_x), + y: Math.round(mouse_y), + color: null, + is_vector: true + }; + app.State.do_action( + new app.Actions.Bundle_action('new_'+this.name+'_layer', 'New '+this.Helper.ucfirst(this.name)+' Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + shape_mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.shape_mouse_click.x); + var click_y = Math.round(this.shape_mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (e.ctrlKey == true || e.metaKey) { + if (width < height * this.best_ratio) { + width = height * this.best_ratio; + } + else { + height = width / this.best_ratio; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + //more data + config.layer.x = x; + config.layer.y = y; + config.layer.width = width; + config.layer.height = height; + + this.Base_layers.render(); + } + + shape_mouseup(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.shape_mouse_click.x); + var click_y = Math.round(this.shape_mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (e.ctrlKey == true || e.metaKey) { + if (width < height * this.best_ratio) { + width = height * this.best_ratio; + } + else { + height = width / this.best_ratio; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + //more data + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x, + y, + width, + height, + status: null + }), + { merge_with_history: 'new_'+this.name+'_layer' } + ); + } + + render_overlay_parent(ctx){ + //x + if(this.snap_line_info.x !== null) { + this.Helper.draw_special_line( + ctx, + this.snap_line_info.x.start_x, + this.snap_line_info.x.start_y, + this.snap_line_info.x.end_x, + this.snap_line_info.x.end_y + ); + } + + //y + if(this.snap_line_info.y !== null) { + this.Helper.draw_special_line( + ctx, + this.snap_line_info.y.start_x, + this.snap_line_info.y.start_y, + this.snap_line_info.y.end_x, + this.snap_line_info.y.end_y + ); + } + } + + get_snap_positions(exclude_id) { + var snap_positions = { + x: [ + 0, + config.WIDTH/2, + config.WIDTH, + ], + y: [ + 0, + config.HEIGHT/2, + config.HEIGHT, + ], + }; + if(config.guides_enabled == true){ + //use guides + for(var i in config.guides){ + var guide = config.guides[i]; + if(guide.y === null) + snap_positions.x.push(guide.x); + else + snap_positions.y.push(guide.y); + } + } + for(var i in config.layers){ + if(exclude_id != null && exclude_id == config.layers[i].id){ + continue; + } + if(config.layers[i].visible == false + || config.layers[i].x === null || config.layers[i].y === null + || config.layers[i].width === null || config.layers[i].height === null){ + continue; + } + + //x + var x = config.layers[i].x; + if(x > 0 && x < config.WIDTH) + snap_positions.x.push(x); + + var x = config.layers[i].x + config.layers[i].width/2; + if(x > 0 && x < config.WIDTH) + snap_positions.x.push(x); + + var x = config.layers[i].x + config.layers[i].width; + if(x > 0 && x < config.WIDTH) + snap_positions.x.push(x); + + //y + var y = config.layers[i].y; + if(y > 0 && y < config.HEIGHT) + snap_positions.y.push(y); + + var y = config.layers[i].y + config.layers[i].height/2; + if(y > 0 && y < config.HEIGHT) + snap_positions.y.push(y); + + var y = config.layers[i].y + config.layers[i].height; + if(y > 0 && y < config.HEIGHT) + snap_positions.y.push(y); + } + + return snap_positions; + } + + /** + * calculates snap coordinates by current mouse position. + * + * @param event + * @param pos_x + * @param pos_y + * @param exclude_id + * @returns object|null + */ + calc_snap_position(event, pos_x, pos_y, exclude_id) { + var snap_position = { x: null, y: null }; + var params = this.getParams(); + + if(config.SNAP === false || event.shiftKey == true || (event.ctrlKey == true || event.metaKey == true)){ + this.snap_line_info = {x: null, y: null}; + return null; + } + + //settings + var sensitivity = 0.01; + var max_distance = (config.WIDTH + config.HEIGHT) / 2 * sensitivity / config.ZOOM; + + //collect snap positions + if(typeof exclude_id != "undefined") + var snap_positions = this.get_snap_positions(exclude_id); + else + var snap_positions = this.get_snap_positions(); + + //find closest snap positions + var min_value = { + x: null, + y: null, + }; + var min_distance = { + x: null, + y: null, + }; + //x + for(var i in snap_positions.x){ + var distance = Math.abs(pos_x - snap_positions.x[i]); + if(distance < max_distance && (distance < min_distance.x || min_distance.x === null)){ + min_distance.x = distance; + min_value.x = snap_positions.x[i]; + } + } + //y + for(var i in snap_positions.y){ + var distance = Math.abs(pos_y - snap_positions.y[i]); + if(distance < max_distance && (distance < min_distance.y || min_distance.y === null)){ + min_distance.y = distance; + min_value.y = snap_positions.y[i]; + } + } + + //apply snap + var success = false; + + //x + if(min_value.x != null) { + snap_position.x = Math.round(min_value.x); + success = true; + this.snap_line_info.x = { + start_x: min_value.x, + start_y: 0, + end_x: min_value.x, + end_y: config.HEIGHT + }; + } + else{ + this.snap_line_info.x = null; + } + //y + if(min_value.y != null) { + snap_position.y = Math.round(min_value.y); + success = true; + this.snap_line_info.y = { + start_x: 0, + start_y: min_value.y, + end_x: config.WIDTH, + end_y: min_value.y, + }; + } + else{ + this.snap_line_info.y = null; + } + + if(success) { + return snap_position; + } + + return null; + } + +} +export default Base_tools_class; diff --git a/paintplus/frontend/src/js/core/components/color-input.js b/paintplus/frontend/src/js/core/components/color-input.js new file mode 100644 index 0000000..2a440e2 --- /dev/null +++ b/paintplus/frontend/src/js/core/components/color-input.js @@ -0,0 +1,184 @@ +import Helper_class from './../../libs/helpers.js'; +import Dialog_class from './../../libs/popup.js'; +import GUI_colors_class from './../gui/gui-colors.js'; + +const Helper = new Helper_class(); + +/** + * This input opens a custom color picker dialog that is more tightly integrated with the application (swatch selection, etc). + * It can also handle alpha values, whereas native color input can't. + */ + +(function ($) { + + const template = ` +
+ +
+
+ `; + + const on_focus_color_input = (event) => { + const $el = $(event.target.closest('.ui_color_input')); + $el.trigger('focus'); + }; + + const on_blur_color_input = (event) => { + const $el = $(event.target.closest('.ui_color_input')); + $el.trigger('blur'); + }; + + const on_click_color_input = (event) => { + event.preventDefault(); + const $el = $(event.target.closest('.ui_color_input')); + const { value } = $el.data(); + const POP = new Dialog_class(); + let colorsDialog = new GUI_colors_class(); + var settings = { + title: 'Color Picker', + on_finish() { + set_value($el, colorsDialog.COLOR + (colorsDialog.ALPHA < 255 ? colorsDialog.ALPHA.toString(16).padStart(2, '0') : '')); + $el.trigger('input'); + $el.trigger('change'); + colorsDialog = null; + }, + params: [ + { + function() { + var html = '
'; + return html; + } + } + ], + }; + let colorValue; + let alpha = 255; + if (/^\#[0-9A-F]{8}$/gi.test(value)) { + // Hex with alpha + colorValue = value.slice(0, 7); + alpha = parseInt(value.slice(7, 9), 16); + } else if (/^\#[0-9A-F]{6}$/gi.test(value)) { + // Hex without alpha + colorValue = value; + } else { + colorValue = '#000000'; + } + POP.show(settings); + colorsDialog.render_main_colors('dialog'); + colorsDialog.set_color({ hex: colorValue, a: alpha }); + }; + + const set_value = ($el, value) => { + const trimmedValue = (value + '').trim(); + let colorValue; + let opacity = 0; + if (/^\#[0-9A-F]{8}$/gi.test(trimmedValue)) { + // Hex with alpha + colorValue = trimmedValue.slice(0, 7); + opacity = 1 - (parseInt(value.slice(7, 9), 16) * (1 / 255)); + } else if (/^\#[0-9A-F]{6}$/gi.test(trimmedValue)) { + // Hex without alpha + colorValue = trimmedValue; + } else { + return; + } + const { input, overlay } = $el.data(); + overlay.style.opacity = opacity; + input.value = colorValue; + $el.data('value', trimmedValue); + }; + + const set_disabled = ($el, disabled) => { + const { input } = $el.data(); + if (disabled) { + input.setAttribute('disabled', 'disabled'); + } else { + input.removeAttribute('disabled'); + } + $el.data('disabled', disabled); + }; + + $.fn.uiColorInput = function(behavior, ...args) { + let returnValues = []; + for (let i = 0; i < this.length; i++) { + let el = this[i]; + + // Constructor + if (Object.prototype.toString.call(behavior) !== '[object String]') { + const definition = behavior || {}; + + const classList = el.className; + const id = definition.id != null ? definition.id : el.getAttribute('id'); + const inputId = definition.inputId || ''; + const disabled = definition.disabled != null ? definition.disabled : el.hasAttribute('disabled') ? true : false; + const value = definition.value != null ? definition.value : el.value || 0; + const ariaLabeledBy = el.getAttribute('aria-labelledby'); + + let $el; + if (el.parentNode) { + $(el).after(template); + const oldEl = el; + el = el.nextElementSibling; + $(oldEl).remove(); + } else { + const orphanedParent = document.createElement('div'); + orphanedParent.innerHTML = template; + el = orphanedParent.firstElementChild; + } + this[i] = el; + $el = $(el); + + const input = $el.find('input[type="color"]')[0]; + const overlay = $el.find('.alpha_overlay')[0]; + + if (classList) { + el.classList.add(classList); + } + if (id) { + el.setAttribute('id', id); + } + if (inputId) { + input.setAttribute('id', inputId); + } + if (ariaLabeledBy) { + input.setAttribute('aria-labelledby', ariaLabeledBy); + } + + $el.data({ + id, + input, + overlay, + value + }); + + $(input) + .on('click', on_click_color_input) + .on('focus', on_focus_color_input) + .on('blur', on_blur_color_input) + + set_value($el, value); + set_disabled($el, disabled); + } + // Behaviors + else if (behavior === 'set_value') { + const newValue = args[0]; + const $el = $(el); + if ($el.data('value') !== newValue) { + set_value($(el), newValue); + } + } + else if (behavior === 'get_value') { + returnValues.push($(el).data('value')); + } + else if (behavior === 'get_id') { + returnValues.push($(el).data('id')); + } + } + if (returnValues.length > 0) { + return returnValues.length === 1 ? returnValues[0] : returnValues; + } else { + return this; + } + } + +})(jQuery); \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/components/color-picker-gradient.js b/paintplus/frontend/src/js/core/components/color-picker-gradient.js new file mode 100644 index 0000000..f827750 --- /dev/null +++ b/paintplus/frontend/src/js/core/components/color-picker-gradient.js @@ -0,0 +1,213 @@ +import Helper_class from './../../libs/helpers.js'; + +var Helper = new Helper_class(); + +(function ($) { + + const template = ` +
+
+
+
+
+
+
+ +
+
+ `; + + const on_key_down_secondary_pick = (event) => { + const $el = $(event.target.closest('.ui_color_picker_gradient')); + const { hsv } = $el.data(); + const key = event.key; + if (['Left', 'ArrowLeft'].includes(key)) { + event.preventDefault(); + set_hsv($el, { + h: hsv.h, + s: hsv.s - 1/100, + v: hsv.v + }); + $el.trigger('input'); + } + else if (['Right', 'ArrowRight'].includes(key)) { + event.preventDefault(); + set_hsv($el, { + h: hsv.h, + s: hsv.s + 1/100, + v: hsv.v + }); + $el.trigger('input'); + } + else if (['Up', 'ArrowUp'].includes(key)) { + event.preventDefault(); + set_hsv($el, { + h: hsv.h, + s: hsv.s, + v: hsv.v + 1/100 + }); + $el.trigger('input'); + } + else if (['Down', 'ArrowDown'].includes(key)) { + event.preventDefault(); + set_hsv($el, { + h: hsv.h, + s: hsv.s, + v: hsv.v - 1/100 + }); + $el.trigger('input'); + } + }; + + const on_mouse_down_secondary_pick = (event) => { + event.preventDefault(); + const $el = $(event.target.closest('.ui_color_picker_gradient')); + const { secondaryPick, secondaryPickHandle, hsv } = $el.data(); + const clientX = event.touches && event.touches.length > 0 ? event.touches[0].clientX : event.clientX; + const clientY = event.touches && event.touches.length > 0 ? event.touches[0].clientY : event.clientY; + const mouseDownSecondaryPickRect = secondaryPick.getBoundingClientRect(); + + const xRatio = (clientX - mouseDownSecondaryPickRect.left) / (mouseDownSecondaryPickRect.right - mouseDownSecondaryPickRect.left); + const yRatio = (clientY - mouseDownSecondaryPickRect.top) / (mouseDownSecondaryPickRect.bottom - mouseDownSecondaryPickRect.top); + + set_hsv($el, { + h: hsv.h, + s: xRatio, + v: 1 - yRatio + }); + + $el.trigger('input'); + + $el.data({ + mouseDownSecondaryPickRect, + mouseMoveWindowHandler: generate_on_mouse_move_window($el), + mouseUpWindowHandler: generate_on_mouse_up_window($el) + }); + + const $window = $(window); + $window.on('mousemove touchmove', $el.data('mouseMoveWindowHandler')); + $window.on('mouseup touchend', $el.data('mouseUpWindowHandler')); + }; + + const on_touch_move_secondary_pick = (event) => { + event.preventDefault(); + }; + + const generate_on_mouse_move_window = ($el) => { + return (event) => { + const { hsv, mouseDownSecondaryPickRect } = $el.data(); + const clientX = event.touches && event.touches.length > 0 ? event.touches[0].clientX : event.clientX; + const clientY = event.touches && event.touches.length > 0 ? event.touches[0].clientY : event.clientY; + const xRatio = (clientX - mouseDownSecondaryPickRect.left) / (mouseDownSecondaryPickRect.right - mouseDownSecondaryPickRect.left); + const yRatio = (clientY - mouseDownSecondaryPickRect.top) / (mouseDownSecondaryPickRect.bottom - mouseDownSecondaryPickRect.top); + set_hsv($el, { + h: hsv.h, + s: xRatio, + v: 1 - yRatio + }); + $el.trigger('input'); + }; + }; + + const generate_on_mouse_up_window = ($el) => { + return (event) => { + const $window = $(window); + $window.off('mousemove touchmove', $el.data('mouseMoveWindowHandler')); + $window.off('mouseup touchend', $el.data('mouseUpWindowHandler')); + }; + }; + + // All hsv values range from 0 to 1. + const set_hsv = ($el, hsv) => { + const { secondaryPick, secondaryPickHandle, primaryRange } = $el.data(); + hsv.h = Math.max(0, Math.min(1, hsv.h)); + hsv.s = Math.max(0, Math.min(1, hsv.s)); + hsv.v = Math.max(0, Math.min(1, hsv.v)); + $el.data('hsv', hsv); + $(primaryRange).uiRange('set_value', (1 - hsv.h) * 360); + secondaryPick.style.background = Helper.hsvToHex(hsv.h, 1, 1); + secondaryPickHandle.style.left = ((hsv.s) * 100) + '%'; + secondaryPickHandle.style.top = ((1 - hsv.v) * 100) + '%'; + }; + + $.fn.uiColorPickerGradient = function(behavior, ...args) { + let returnValues = []; + for (let i = 0; i < this.length; i++) { + let el = this[i]; + + // Constructor + if (Object.prototype.toString.call(behavior) !== '[object String]') { + const definition = behavior || {}; + + const id = definition.id != null ? definition.id : el.getAttribute('id'); + const label = definition.label != null ? definition.label : el.getAttribute('aria-label'); + const hsv = definition.hsv || { h: 0, s: 0, v: 0 }; + + $(el).after(template); + const oldEl = el; + el = el.nextElementSibling; + $(oldEl).remove(); + this[i] = el; + + if (id) { + el.setAttribute('id', id); + } + if (label) { + el.setAttribute('aria-label', label); + } + + const $el = $(el); + + const $primaryRange = $($el.find('.primary_pick input').get(0)); + $primaryRange + .uiRange({ vertical: true }) + .uiRange('set_background', 'linear-gradient(to bottom, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%)') + .on('input', () => { + const { hsv } = $el.data(); + set_hsv($el, { + h: 1 - ($primaryRange.uiRange('get_value') / 360), + s: hsv.s, + v: hsv.v + }); + $el.trigger('input'); + }); + + $el.find('> input').uiRange(); + + const secondaryPick = $el.find('.secondary_pick')[0]; + + $el.data({ + primaryRange: $primaryRange[0], + secondaryPick, + secondaryPickHandle: $el.find('.secondary_pick .handle')[0], + hsv + }); + + set_hsv($el, hsv); + + $(secondaryPick).on('keydown', on_key_down_secondary_pick); + $(secondaryPick).on('mousedown touchstart', on_mouse_down_secondary_pick); + $(secondaryPick).on('touchmove', on_touch_move_secondary_pick); + } + // Behaviors + else if (behavior === 'set_hsv') { + const $el = $(el); + const hsv = $el.data('hsv'); + const newHsv = args[0]; + if (newHsv && (hsv.h !== newHsv.h || hsv.s !== newHsv.s || hsv.v !== newHsv.v)) { + set_hsv($(el), newHsv); + } + } + else if (behavior === 'get_hsv') { + const hsv = $(el).data('hsv'); + returnValues.push(JSON.parse(JSON.stringify(hsv))); + } + } + if (returnValues.length > 0) { + return returnValues.length === 1 ? returnValues[0] : returnValues; + } else { + return this; + } + }; + +})(jQuery); \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/components/index.js b/paintplus/frontend/src/js/core/components/index.js new file mode 100644 index 0000000..4db60ee --- /dev/null +++ b/paintplus/frontend/src/js/core/components/index.js @@ -0,0 +1,6 @@ + +import './color-input.js'; +import './color-picker-gradient.js'; +import './number-input.js'; +import './range.js'; +import './swatches.js'; diff --git a/paintplus/frontend/src/js/core/components/number-input.js b/paintplus/frontend/src/js/core/components/number-input.js new file mode 100644 index 0000000..6f1243a --- /dev/null +++ b/paintplus/frontend/src/js/core/components/number-input.js @@ -0,0 +1,306 @@ +import Helper_class from './../../libs/helpers.js'; + +var Helper = new Helper_class(); + +/** + * The purpose of using this class vs a native input[type="number"] is for custom styling and + * to allow for gestures on mobile that makes it easier to use with a thumb on a touch screen (future implementation) + */ + +(function ($) { + + const template = ` +
+ + + +
+ `; + + const on_focus_number_input = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + $el.trigger('focus', event); + }; + + const on_blur_number_input = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + $el.trigger('blur', event); + }; + + const on_input_number_input = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const value = $el.data('input').value; + if (value != '') { + set_value($el, $el.data('input').value); + } + $el.trigger('input', event); + }; + + const on_change_number_input = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { input, min } = $el.data(); + let value = input.value; + if (value === '') { + value = 0; + } + set_value($el, value); + $el.trigger('change', event); + }; + + const on_wheel_number_input = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { value, step, disabled } = $el.data(); + event.preventDefault(); + const delta = (event.originalEvent.deltaY > 0 ? -1 : (event.originalEvent.deltaY < 0 ? 1 : 0)); + if (!disabled && delta !== 0) { + set_value($el, (isNaN(value) ? 0 : value) + (step * delta)); // Intentionally not using get_step_amount + $el.trigger('input'); + } + } + + const on_touch_start_increase_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { value, buttonRepeatTimeout, buttonRepeatInterval, disabled } = $el.data(); + if (!disabled) { + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + set_value($el, (isNaN(value) ? 0 : value) + get_step_amount($el, true)); + $el.trigger('input'); + } + }; + + const on_mouse_down_increase_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { value, buttonRepeatTimeout, buttonRepeatInterval, disabled } = $el.data(); + if (!disabled) { + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + set_value($el, (isNaN(value) ? 0 : value) + get_step_amount($el, true)); + $el.trigger('input'); + $el.data('buttonRepeatTimeout', setTimeout(() => { + $el.data('buttonRepeatInterval', setInterval(() => { + const { value } = $el.data(); + set_value($el, value + get_step_amount($el, true)); + $el.trigger('input'); + }, 50)); + }, 400)); + } + }; + + const on_mouse_up_increase_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { buttonRepeatTimeout, buttonRepeatInterval } = $el.data(); + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + }; + + const on_touch_start_decrease_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { value, buttonRepeatTimeout, buttonRepeatInterval, disabled } = $el.data(); + if (!disabled) { + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + set_value($el, (isNaN(value) ? 0 : value) - get_step_amount($el, false)); + $el.trigger('input'); + } + }; + + const on_mouse_down_decrease_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { value, buttonRepeatTimeout, buttonRepeatInterval, disabled } = $el.data(); + if (!disabled) { + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + set_value($el, (isNaN(value) ? 0 : value) - get_step_amount($el, false)); + $el.trigger('input'); + $el.data('buttonRepeatTimeout', setTimeout(() => { + $el.data('buttonRepeatInterval', setInterval(() => { + const { value } = $el.data(); + set_value($el, value - get_step_amount($el, false)); + $el.trigger('input'); + }, 50)); + }, 400)); + } + }; + + const on_mouse_up_decrease_button = (event) => { + const $el = $(event.target.closest('.ui_number_input')); + const { buttonRepeatTimeout, buttonRepeatInterval } = $el.data(); + clearTimeout(buttonRepeatTimeout); + clearInterval(buttonRepeatInterval); + }; + + const set_value = ($el, value) => { + const { min, max, step, stepDecimalPlaces, input } = $el.data(); + if (typeof value === 'string') { + value = parseFloat(value); + } + if (!isNaN(value)) { + value = parseFloat((step * Math.round(value / step)).toFixed(stepDecimalPlaces)); + value = Math.max(min, Math.min(max, value)); + if (value + '.' !== input.value) { + input.value = value; + } + } else { + value = parseFloat(null); + input.value = ''; + } + $el.data('value', value); + }; + + const set_disabled = ($el, disabled) => { + const { input } = $el.data(); + if (disabled) { + input.setAttribute('disabled', 'disabled'); + } else { + input.removeAttribute('disabled'); + } + $el.data('disabled', disabled); + }; + + const get_step_amount = ($el, increasing) => { + const { value, step, exponentialStepButtons } = $el.data(); + if (exponentialStepButtons) { + let amount = step; + let absValue = Math.abs((isNaN(value) ? 0 : value)); + if (absValue >= (increasing ? 500 : 501)) + amount = 100; + else if (absValue >= (increasing ? 100 : 101)) + amount = 50; + else if (absValue >= (increasing ? 10 : 11)) + amount = 10; + else if (absValue >= (increasing ? 5 : 6)) + amount = 5; + else + amount = 1; + return amount; + } else { + return step; + } + }; + + $.fn.uiNumberInput = function(behavior, ...args) { + let returnValues = []; + for (let i = 0; i < this.length; i++) { + let el = this[i]; + + // Constructor + if (Object.prototype.toString.call(behavior) !== '[object String]') { + const definition = behavior || {}; + + const classList = el.className; + const id = definition.id != null ? definition.id : el.getAttribute('id'); + const min = definition.min != null ? definition.min : parseFloat(el.getAttribute('min')) || null; + const max = definition.max != null ? definition.max : parseFloat(el.getAttribute('max')) || null; + const step = definition.step != null ? definition.step : el.hasAttribute('step') ? parseFloat(el.getAttribute('step')) : 1; + const exponentialStepButtons = !!definition.exponentialStepButtons; + const disabled = definition.disabled != null ? definition.disabled : el.hasAttribute('disabled') ? true : false; + const value = definition.value != null ? definition.value : parseFloat(el.value) || 0; + const ariaLabeledBy = el.getAttribute('aria-labelledby'); + + let $el; + if (el.parentNode) { + $(el).after(template); + const oldEl = el; + el = el.nextElementSibling; + $(oldEl).remove(); + } else { + const orphanedParent = document.createElement('div'); + orphanedParent.innerHTML = template; + el = orphanedParent.firstElementChild; + } + this[i] = el; + $el = $(el); + + const input = $el.find('input[type="number"]')[0]; + const increaseButton = $el.find('.increase_number')[0]; + const decreaseButton = $el.find('.decrease_number')[0]; + + if (classList) { + el.classList.add(classList); + } + if (id) { + el.setAttribute('id', id); + } + if (ariaLabeledBy) { + input.setAttribute('aria-labelledby', ariaLabeledBy); + } + if (min != null) { + input.setAttribute('min', min); + } + if (max != null) { + input.setAttribute('max', max); + } + if (Math.floor(step) === step) { + input.setAttribute('step', step); + } else { + input.setAttribute('step', 'any'); + } + + let stepDecimalPlaces = 0; + if ((step % 1) != 0) + stepDecimalPlaces = step.toString().split(".")[1].length; + + $el.data({ + id, + input, + increaseButton, + decreaseButton, + buttonRepeatTimeout: undefined, + buttonRepeatInterval: undefined, + value, + min, + max, + step, + stepDecimalPlaces, + exponentialStepButtons + }); + + $(input) + .on('focus', on_focus_number_input) + .on('blur', on_blur_number_input) + .on('input', on_input_number_input) + .on('change', on_change_number_input) + .on('wheel', on_wheel_number_input); + $(increaseButton) + .on('touchstart', on_touch_start_increase_button) + .on('mousedown', on_mouse_down_increase_button) + .on('mouseup mouseleave touchend', on_mouse_up_increase_button); + $(decreaseButton) + .on('touchstart', on_touch_start_decrease_button) + .on('mousedown', on_mouse_down_decrease_button) + .on('mouseup mouseleave', on_mouse_up_decrease_button); + + set_value($el, value); + set_disabled($el, disabled); + } + // Behaviors + else if (behavior === 'set_value') { + const newValue = parseFloat(args[0]); + const $el = $(el); + if ($el.data('value') !== newValue) { + set_value($(el), newValue); + } + } + else if (behavior === 'get_value') { + returnValues.push($(el).data('value')); + } + else if (behavior === 'get_id') { + returnValues.push($(el).data('id')); + } + else if (behavior === 'set_disabled') { + const newValue = !!args[0]; + set_disabled($(el), newValue); + } + else if (behavior === 'get_disabled') { + returnValues.push($(el).data('disabled')); + } + } + if (returnValues.length > 0) { + return returnValues.length === 1 ? returnValues[0] : returnValues; + } else { + return this; + } + }; + +})(jQuery); \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/components/provider-badge.js b/paintplus/frontend/src/js/core/components/provider-badge.js new file mode 100644 index 0000000..35f0ca0 --- /dev/null +++ b/paintplus/frontend/src/js/core/components/provider-badge.js @@ -0,0 +1,118 @@ +/** + * ProviderBadge — compact status indicator in the left toolbar footer. + * Shows a dot + 3-5 char label; all details in the tooltip. + */ + +import { getCapabilities } from '../../api/capabilities.js'; + +export async function mountProviderBadge(container) { + var caps = await getCapabilities(); + + var badge = document.createElement('div'); + badge.id = 'provider-badge'; + badge.style.cssText = [ + 'display:flex', 'flex-direction:column', 'align-items:center', 'gap:2px', + 'padding:4px 2px 4px', + 'font-size:9px', 'font-family:sans-serif', 'line-height:1.2', + 'cursor:default', 'user-select:none', + 'width:100%', 'box-sizing:border-box', + 'text-align:center', 'word-break:break-word', + ].join(';'); + + var dot = document.createElement('span'); + dot.style.cssText = 'width:8px;height:8px;border-radius:50%;display:block;flex-shrink:0;'; + + var label = document.createElement('span'); + label.style.cssText = 'color:inherit;max-width:36px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block;'; + + var remote = caps.remote || {}; + var local = caps.local || {}; + + if (remote.provider === 'local_gpu') { + var gpuName = _shortGpuName(local.gpu_device); + var tier = local.gpu_tier || ''; + + if (remote.healthy) { + dot.style.background = '#44cc44'; + badge.style.color = '#aaffaa'; + label.textContent = _shortTier(tier); + + var flagList = [ + local.gpu_fp16 && 'fp16', + local.gpu_bf16 && 'bf16', + local.gpu_fp8 && 'fp8', + local.gpu_tensor_cores && 'TC', + ].filter(Boolean).join(' '); + + badge.title = [ + gpuName, + 'VRAM: ' + local.gpu_vram_total + ' GB total / ' + local.gpu_vram_free + ' GB free', + 'CC: ' + local.gpu_cc + ' Eff VRAM: ' + local.gpu_eff_vram + ' GB', + flagList ? 'Flags: ' + flagList : '', + tier ? 'Tier: ' + tier : '', + (local.local_gpu_warnings || []).length + ? 'Warnings:\n' + local.local_gpu_warnings.join('\n') + : '', + ].filter(Boolean).join('\n'); + } else { + dot.style.background = '#ffaa00'; + badge.style.color = '#ffdd88'; + label.textContent = 'GPU?'; + badge.title = 'local_gpu configured but diffusers may not be installed.\nCheck container logs.'; + } + } else if (remote.provider && remote.healthy) { + dot.style.background = '#44cc44'; + badge.style.color = '#aaffaa'; + label.textContent = _shortProvider(remote.provider); + + var opLines = Object.entries(remote.operations || {}) + .map(([op, s]) => op + ': ' + (s.provider || remote.provider) + ' ' + (s.healthy ? '✓' : '✗')) + .join('\n'); + badge.title = ('Provider: ' + remote.provider) + (opLines ? '\n' + opLines : ''); + } else if (remote.provider && !remote.healthy) { + dot.style.background = '#ffaa00'; + badge.style.color = '#ffdd88'; + label.textContent = _shortProvider(remote.provider) + '?'; + badge.title = remote.provider + ' configured but not reachable.\nCheck your .env URL.'; + } else { + dot.style.background = '#888888'; + badge.style.color = '#aaaaaa'; + label.textContent = local.lama ? 'LaMa' : 'Local'; + badge.title = 'Local only (no generative AI).\nSet AI_PROVIDER in .env to enable.'; + } + + badge.appendChild(dot); + badge.appendChild(label); + + if (container) { + container.appendChild(badge); + } + + return badge; +} + +function _shortGpuName(name) { + return (name || 'GPU') + .replace(/^NVIDIA GeForce\s+/i, '') + .replace(/^NVIDIA Quadro\s+/i, '') + .replace(/^NVIDIA\s+/i, '') + .replace(/^AMD Radeon\s+/i, ''); +} + +function _shortTier(tier) { + if (!tier) return 'GPU'; + // sdxl_offload → SDXL, flux → FLUX, sd15 → SD15 + return tier + .replace(/_offload$/, '') + .replace(/_cpu$/, '') + .toUpperCase() + .slice(0, 6); +} + +function _shortProvider(p) { + var map = { + openai: 'OAI', replicate: 'Rep', stability: 'Stab', + invokeai: 'Inv', comfyui: 'CUI', local_gpu: 'GPU', + }; + return map[p] || (p || 'AI').slice(0, 4); +} diff --git a/paintplus/frontend/src/js/core/components/range.js b/paintplus/frontend/src/js/core/components/range.js new file mode 100644 index 0000000..ea00322 --- /dev/null +++ b/paintplus/frontend/src/js/core/components/range.js @@ -0,0 +1,234 @@ + +(function ($) { + + const template = ` +
+
+
+
+
+
+ `; + + const on_keydown_range = (event) => { + const $el = $(event.target.closest('.ui_range')); + const key = event.key; + const { value, step, min, max } = $el.data(); + if (['Left', 'ArrowLeft', 'Down', 'ArrowDown'].includes(key)) { + event.preventDefault(); + set_value($el, value - step); + $el.trigger('input'); + } + else if (['Right', 'ArrowRight', 'Up', 'ArrowUp'].includes(key)) { + event.preventDefault(); + set_value($el, value + step); + $el.trigger('input'); + } + else if (['PageUp'].includes(key)) { + event.preventDefault(); + set_value($el, value + (step * 10)); + $el.trigger('input'); + } + else if (['PageDown'].includes(key)) { + event.preventDefault(); + set_value($el, value - (step * 10)); + $el.trigger('input'); + } + else if (['Home'].includes(key)) { + event.preventDefault(); + set_value($el, min); + $el.trigger('input'); + } + else if (['End'].includes(key)) { + event.preventDefault(); + set_value($el, max); + $el.trigger('input'); + } + }; + + const on_wheel_range = (event) => { + const $el = $(event.target.closest('.ui_range')); + if (document.activeElement === $el[0]) { + const { value, step } = $el.data(); + if (event.originalEvent.deltaY < 0) { + event.preventDefault(); + set_value($el, value + step); + $el.trigger('input'); + } + else if (event.originalEvent.deltaY > 0) { + event.preventDefault(); + set_value($el, value - step); + $el.trigger('input'); + } + } + }; + + const on_mouse_down_range = (event) => { + event.preventDefault(); + const target = event.touches && event.touches.length > 0 ? event.touches[0].target : event.target; + const $el = $(target.closest('.ui_range')); + const { handle, paddedTrack, value, min, max, vertical } = $el.data(); + const mouseDownClientX = event.touches && event.touches.length > 0 ? event.touches[0].clientX : event.clientX; + const mouseDownClientY = event.touches && event.touches.length > 0 ? event.touches[0].clientY : event.clientY; + const mouseDownPaddedTrackRect = paddedTrack.getBoundingClientRect(); + let mouseDownValue = value; + if (target !== handle) { + let range, valueInRange; + if (vertical) { + range = mouseDownPaddedTrackRect.top - mouseDownPaddedTrackRect.bottom; + valueInRange = mouseDownClientY - mouseDownPaddedTrackRect.bottom; + } else { + range = mouseDownPaddedTrackRect.right - mouseDownPaddedTrackRect.left; + valueInRange = mouseDownClientX - mouseDownPaddedTrackRect.left; + } + const ratio = Math.max(0, Math.min(1, valueInRange / range)); + mouseDownValue = (max - min) * ratio; + set_value($el, mouseDownValue); + $el.trigger('input'); + } + $el.data({ + mouseDownValue, + mouseDownClientX, + mouseDownClientY, + mouseDownPaddedTrackRect, + mouseMoveWindowHandler: generate_on_mouse_move_window($el), + mouseUpWindowHandler: generate_on_mouse_up_window($el) + }); + $el.addClass('active'); + const $window = $(window); + $window.on('mousemove touchmove', $el.data('mouseMoveWindowHandler')); + $window.on('mouseup touchend', $el.data('mouseUpWindowHandler')); + $el[0].focus(); + }; + + const on_touch_move_range = (event) => { + event.preventDefault(); + }; + + const generate_on_mouse_move_window = ($el) => { + return (event) => { + event.preventDefault(); + event.stopPropagation(); + const { mouseDownValue, min, max, vertical, mouseDownClientX, mouseDownClientY, mouseDownPaddedTrackRect } = $el.data(); + let range, offset, startValue; + if (vertical) { + const clientY = event.touches && event.touches.length > 0 ? event.touches[0].clientY : event.clientY; + range = mouseDownPaddedTrackRect.top - mouseDownPaddedTrackRect.bottom; + const mouseDownValueInPixelRange = ((mouseDownValue - min) / (max - min)) * range; + startValue = mouseDownClientY - mouseDownPaddedTrackRect.bottom; + offset = clientY - mouseDownClientY + (mouseDownValueInPixelRange - startValue); + } else { + const clientX = event.touches && event.touches.length > 0 ? event.touches[0].clientX : event.clientX; + range = mouseDownPaddedTrackRect.right - mouseDownPaddedTrackRect.left; + const mouseDownValueInPixelRange = ((mouseDownValue - min) / (max - min)) * range; + startValue = mouseDownClientX - mouseDownPaddedTrackRect.left; + offset = clientX - mouseDownClientX + (mouseDownValueInPixelRange - startValue); + } + const ratio = Math.max(0, Math.min(1, (startValue + offset) / range)); + const value = (max - min) * ratio; + set_value($el, value); + $el.trigger('input'); + }; + }; + + const generate_on_mouse_up_window = ($el) => { + return (event) => { + const $window = $(window); + $el.removeClass('active'); + $window.off('mousemove touchmove', $el.data('mouseMoveWindowHandler')); + $window.off('mouseup touchend', $el.data('mouseUpWindowHandler')); + }; + }; + + const set_value = ($el, value) => { + const { bar, min, max, step, vertical } = $el.data(); + value = step * Math.round(value / step); + value = Math.max(min, Math.min(max, value)); + $el.data('value', value); + $el.attr('aria-valuemin', min); + $el.attr('aria-valuemax', max); + $el.attr('aria-valuenow', value); + if (vertical) { + bar.style.height = (((value - min) / (max - min)) * 100) + '%'; + } else { + bar.style.width = (((value - min) / (max - min)) * 100) + '%'; + } + }; + + $.fn.uiRange = function(behavior, ...args) { + let returnValues = []; + for (let i = 0; i < this.length; i++) { + let el = this[i]; + + // Constructor + if (Object.prototype.toString.call(behavior) !== '[object String]') { + const definition = behavior || {}; + + const classList = el.className; + const id = definition.id != null ? definition.id : el.getAttribute('id'); + const value = definition.value != null ? definition.value : parseFloat(el.value) || 0; + const min = definition.min != null ? definition.min : parseFloat(el.getAttribute('min')) || 0; + const max = definition.max != null ? definition.max : parseFloat(el.getAttribute('max')) || 0; + const step = definition.step != null ? definition.step : el.hasAttribute('step') ? parseFloat(el.getAttribute('step')) : 1; + const vertical = !!definition.vertical; + + $(el).after(template); + const oldEl = el; + el = el.nextElementSibling; + $(oldEl).remove(); + this[i] = el; + const $el = $(el); + + if (classList) { + el.classList.add(classList); + } + if (vertical) { + el.classList.add('vertical'); + } + if (id) { + el.setAttribute('id', id); + } + + $el.data({ + paddedTrack: $('.padded_track', el).get(0), + bar: $('.bar', el).get(0), + handle: $('.handle', el).get(0), + vertical, + value, + min, + max, + step + }); + + set_value($el, value); + + $el + .on('mousedown touchstart', on_mouse_down_range) + .on('touchmove', on_touch_move_range) + .on('keydown', on_keydown_range) + .on('wheel', on_wheel_range); + } + // Behaviors + else if (behavior === 'set_background') { + const backgroundStyle = args[0]; + $(el).data('paddedTrack').style.background = backgroundStyle; + } + else if (behavior === 'set_value') { + const newValue = parseFloat(args[0]); + const $el = $(el); + if ($el.data('value') !== newValue) { + set_value($(el), newValue); + } + } + else if (behavior === 'get_value') { + returnValues.push($(el).data('value')); + } + } + if (returnValues.length > 0) { + return returnValues.length === 1 ? returnValues[0] : returnValues; + } else { + return this; + } + }; + +})(jQuery); \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/components/swatches.js b/paintplus/frontend/src/js/core/components/swatches.js new file mode 100644 index 0000000..e7019b6 --- /dev/null +++ b/paintplus/frontend/src/js/core/components/swatches.js @@ -0,0 +1,170 @@ +(function ($) { + + const template = ` +
+
+
+
+ `; + + const on_key_down_swatches = (event) => { + const $el = $(event.target.closest('.ui_swatches')); + const key = event.key; + const { rows, count, selectedIndex } = $el.data(); + if (['Left', 'ArrowLeft'].includes(key)) { + event.preventDefault(); + set_selected_index($el, selectedIndex - 1); + $el.trigger('input'); + } + else if (['Right', 'ArrowRight'].includes(key)) { + event.preventDefault(); + set_selected_index($el, selectedIndex + 1); + $el.trigger('input'); + } + else if (['Up', 'ArrowUp'].includes(key)) { + event.preventDefault(); + set_selected_index($el, selectedIndex - Math.floor(count / rows)); + $el.trigger('input'); + } + else if (['Down', 'ArrowDown'].includes(key)) { + event.preventDefault(); + set_selected_index($el, selectedIndex + Math.floor(count / rows)); + $el.trigger('input'); + } + }; + + const on_click_swatches = (event) => { + const target = event.target; + const $el = $(target.closest('.ui_swatches')); + if (target.classList.contains('swatch')) { + const { swatches } = $el.data(); + set_selected_index($el, swatches.indexOf(target)); + $el.trigger('input'); + } + }; + + const set_selected_index = ($el, index) => { + const { readonly, swatches } = $el.data(); + if (swatches[index]) { + $el.data('selectedIndex', index); + if (!readonly) { + $el.find('.active').removeClass('active'); + $(swatches[index]).addClass('active'); + } + } + }; + + const set_selected_hex = ($el, hex) => { + const { selectedIndex, swatches } = $el.data(); + if (/^\#[0-9A-F]{6}$/gi.test(hex)) { + const swatch = swatches[selectedIndex]; + $(swatch) + .data('hex', hex) + .css('background-color', hex); + } + }; + + const set_all_hex = ($el, hexArray) => { + hexArray = hexArray || []; + const { swatches } = $el.data(); + for (let i = 0; i < swatches.length; i++) { + if (hexArray[i]) { + const hex = hexArray[i]; + if (/^\#[0-9A-F]{6}$/gi.test(hex)) { + $(swatches[i]) + .data('hex', hex) + .css('background-color', hex); + } + } else { + break; + } + } + } + + $.fn.uiSwatches = function(behavior, ...args) { + let returnValues = []; + for (let i = 0; i < this.length; i++) { + let el = this[i]; + + // Constructor + if (Object.prototype.toString.call(behavior) !== '[object String]') { + const definition = behavior || {}; + + const id = definition.id != null ? definition.id : el.getAttribute('id'); + const cols = definition.cols; + const rows = definition.rows || 1; + const count = definition.count || 10; + const readonly = definition.readonly || false; + const selectedIndex = definition.selectedIndex != null ? definition.selectedIndex : 0; + + $(el).after(template); + const oldEl = el; + el = el.nextElementSibling; + $(oldEl).remove(); + this[i] = el; + + const $el = $(el); + + const swatchGroup = $el.find('.swatch_group')[0]; + + if (id) { + el.setAttribute('id', id); + } + if (cols) { + swatchGroup.classList.add('cols_' + cols); + } + swatchGroup.classList.add('rows_' + rows); + + const swatches = []; + for (let i = 0; i < count; i++) { + const swatch = document.createElement('div'); + swatch.classList.add('swatch'); + $(swatch).data('hex', '#ffffff'); + swatches.push(swatch); + swatchGroup.appendChild(swatch); + if (i === selectedIndex && !readonly) { + swatch.classList.add('active'); + } + } + + $el.data({ + selectedIndex, + swatchGroup, + swatches, + count, + cols, + rows, + readonly + }); + + $el + .on('click', on_click_swatches) + .on('keydown', on_key_down_swatches); + } + // Behaviors + else if (behavior === 'set_selected_hex') { + const newValue = args[0] + ''; + set_selected_hex($(el), newValue); + } + else if (behavior === 'get_selected_hex') { + const { selectedIndex, swatches } = $(el).data(); + returnValues.push($(swatches[selectedIndex]).data('hex')); + } + else if (behavior === 'set_all_hex') { + set_all_hex($(el), args[0]); + } + else if (behavior === 'get_all_hex') { + const { swatches } = $(el).data(); + for (let swatch of swatches) { + returnValues.push($(swatch).data('hex')); + } + } + } + if (returnValues.length > 0) { + return returnValues.length === 1 ? returnValues[0] : returnValues; + } else { + return this; + } + }; + +})(jQuery); \ No newline at end of file diff --git a/paintplus/frontend/src/js/core/gui/gui-colors.js b/paintplus/frontend/src/js/core/gui/gui-colors.js new file mode 100644 index 0000000..48b010f --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-colors.js @@ -0,0 +1,587 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../../config.js'; +import Helper_class from './../../libs/helpers.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; + +const Helper = new Helper_class(); + +const sidebarTemplate = ` +
+
+
+ + + +
+
+
+
+
+
+ +
+ + +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+`; + +const dialogTemplate = ` +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ + +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+
+
+`; + +/** + * GUI class responsible for rendering colors block on right sidebar + */ +class GUI_colors_class { + + constructor() { + this.el = null; + this.COLOR = '#000000'; + this.ALPHA = 255; + this.colorNotSet = true; + this.uiType = null; + this.butons = null; + this.sections = null; + this.inputs = null; + this.Helper = new Helper_class(); + this.Tools_translate = new Tools_translate_class(); + } + + render_main_colors(uiType) { + this.uiType = uiType || 'sidebar'; + if (this.uiType === 'dialog') { + this.el = document.getElementById('dialog_color_picker'); + this.el.innerHTML = dialogTemplate; + } else { + var saved_color = this.Helper.getCookie('color'); + if (saved_color != null) config.COLOR = saved_color; + this.el = document.getElementById('toggle_colors'); + this.el.innerHTML = sidebarTemplate; + } + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, this.el); + } + this.init_components(); + this.render_ui_deferred = Helper.throttle(this.render_ui_deferred, 50); + } + + init_components() { + + // Store button references + this.buttons = { + toggleColorSwatches: $('#toggle_color_swatches_section_button', this.el), + toggleColorPicker: $('#toggle_color_picker_section_button', this.el), + toggleColorChannels: $('#toggle_color_channels_section_button', this.el) + }; + + // Store UI section references + this.sections = { + swatches: $('#color_section_swatches', this.el), + swatchesPlaceholder: document.createComment('Placeholder comment for color swatches'), + picker: $('#color_section_picker', this.el), + pickerPlaceholder: document.createComment('Placeholder comment for color picker'), + channels: $('#color_section_channels', this.el), + channelsPlaceholder: document.createComment('Placeholder comment for color channels') + }; + + // Store references to all inputs in DOM + const idPrefix = this.uiType === 'dialog' ? 'dialog_' : ''; + this.inputs = { + sample: $(`#${idPrefix}selected_color_sample`, this.el), + swatches: $(`#${idPrefix}color_swatches`, this.el), + pickerGradient: $(`#${idPrefix}color_picker_gradient`, this.el), + hex: $(`#${idPrefix}color_hex`, this.el), + rgb: { + r: { + range: $(`#${idPrefix}rgb_r_range`, this.el), + number: $(`#${idPrefix}rgb_r`, this.el) + }, + g: { + range: $(`#${idPrefix}rgb_g_range`, this.el), + number: $(`#${idPrefix}rgb_g`, this.el) + }, + b: { + range: $(`#${idPrefix}rgb_b_range`, this.el), + number: $(`#${idPrefix}rgb_b`, this.el) + }, + a: { + range: $(`#${idPrefix}rgb_a_range`, this.el), + number: $(`#${idPrefix}rgb_a`, this.el) + } + }, + hsl: { + h: { + range: $(`#${idPrefix}hsl_h_range`, this.el), + number: $(`#${idPrefix}hsl_h`, this.el) + }, + s: { + range: $(`#${idPrefix}hsl_s_range`, this.el), + number: $(`#${idPrefix}hsl_s`, this.el) + }, + l: { + range: $(`#${idPrefix}hsl_l_range`, this.el), + number: $(`#${idPrefix}hsl_l`, this.el) + } + } + }; + + // Handle toggle for color swatches section + this.buttons.toggleColorSwatches + .on('click', () => { + this.buttons.toggleColorSwatches.attr('aria-pressed', 'true' === this.buttons.toggleColorSwatches.attr('aria-pressed') ? 'false' : 'true'); + const isPressed = this.buttons.toggleColorSwatches.attr('aria-pressed') === 'true'; + if (isPressed) { + this.sections.swatchesPlaceholder.parentNode.insertBefore(this.sections.swatches[0], this.sections.swatchesPlaceholder.nextSibling); + this.sections.swatchesPlaceholder.parentNode.removeChild(this.sections.swatchesPlaceholder); + } else { + this.sections.swatches[0].parentNode.insertBefore(this.sections.swatchesPlaceholder, this.sections.swatches[0].nextSibling); + this.sections.swatches[0].parentNode.removeChild(this.sections.swatches[0]); + } + Helper.setCookie('toggle_color_swatches', isPressed ? 1 : 0); + }); + // Restore toggle preference, default to hidden for swatches + const saved_toggle_color_swatches = Helper.getCookie('toggle_color_swatches'); + if (saved_toggle_color_swatches === 0 || saved_toggle_color_swatches == null) { + this.buttons.toggleColorSwatches.trigger('click'); + } + + // Handle toggle for color picker section + this.buttons.toggleColorPicker + .on('click', () => { + this.buttons.toggleColorPicker.attr('aria-pressed', 'true' === this.buttons.toggleColorPicker.attr('aria-pressed') ? 'false' : 'true'); + const isPressed = this.buttons.toggleColorPicker.attr('aria-pressed') === 'true'; + if (isPressed) { + this.sections.pickerPlaceholder.parentNode.insertBefore(this.sections.picker[0], this.sections.pickerPlaceholder.nextSibling); + this.sections.pickerPlaceholder.parentNode.removeChild(this.sections.pickerPlaceholder); + } else { + this.sections.picker[0].parentNode.insertBefore(this.sections.pickerPlaceholder, this.sections.picker[0].nextSibling); + this.sections.picker[0].parentNode.removeChild(this.sections.picker[0]); + } + Helper.setCookie('toggle_color_picker', isPressed ? 1 : 0); + }); + this.inputs.sample.on('click', (event) => { + this.buttons.toggleColorPicker.click(); + }); + + // Restore toggle preference, default to visible for picker + const saved_toggle_color_picker = Helper.getCookie('toggle_color_picker'); + if (saved_toggle_color_picker === 0) { + this.buttons.toggleColorPicker.trigger('click'); + } + + // Handle toggle for color channels section + this.buttons.toggleColorChannels + .on('click', () => { + this.buttons.toggleColorChannels.attr('aria-pressed', 'true' === this.buttons.toggleColorChannels.attr('aria-pressed') ? 'false' : 'true'); + const isPressed = this.buttons.toggleColorChannels.attr('aria-pressed') === 'true'; + if (isPressed) { + this.sections.channelsPlaceholder.parentNode.insertBefore(this.sections.channels[0], this.sections.channelsPlaceholder.nextSibling); + this.sections.channelsPlaceholder.parentNode.removeChild(this.sections.channelsPlaceholder); + } else { + this.sections.channels[0].parentNode.insertBefore(this.sections.channelsPlaceholder, this.sections.channels[0].nextSibling); + this.sections.channels[0].parentNode.removeChild(this.sections.channels[0]); + } + Helper.setCookie('toggle_color_channels', isPressed ? 1 : 0); + }); + // Restore toggle preference, default to hidden for swatches + const saved_toggle_color_channels = Helper.getCookie('toggle_color_channels'); + if (saved_toggle_color_channels === 0 || saved_toggle_color_channels == null) { + this.buttons.toggleColorChannels.trigger('click'); + } + + // Initialize color swatches + this.inputs.swatches + .uiSwatches({ rows: 3, cols: 7, count: 21, readonly: this.uiType === 'dialog' }) + .on('input', () => { + this.set_color({ + hex: this.inputs.swatches.uiSwatches('get_selected_hex') + }); + }); + if (this.uiType === 'dialog') { + this.inputs.swatches.uiSwatches('set_all_hex', config.swatches.default); + } + + // Initialize color picker gradient + this.inputs.pickerGradient + .uiColorPickerGradient() + .on('input', () => { + const hsv = this.inputs.pickerGradient.uiColorPickerGradient('get_hsv'); + this.set_color({ + h: hsv.h * 360, + s: hsv.s * 100, + v: hsv.v * 100 + }); + }); + + // Initialize hex entry + this.inputs.hex + .on('input', (event) => { + const value = this.inputs.hex.val(); + const trimmedValue = value.trim(); + if (value !== trimmedValue) { + this.inputs.hex.val(trimmedValue); + } + this.inputs.hex[0].setCustomValidity(/^\#[0-9A-F]{6}$/gi.test(trimmedValue) ? '' : 'Invalid Hex Code'); + this.set_color({ hex: this.inputs.hex.val() }); + }) + .on('blur', () => { + const value = this.inputs.hex.val(); + if (!/^\#[0-9A-F]{6}$/gi.test(value)) { + this.inputs.hex.val(this.uiType === 'dialog' ? this.COLOR : config.COLOR); + this.inputs.hex[0].setCustomValidity(''); + } + }); + + // Initialize the color sliders + const sliderInputs = [ + ...Object.entries(this.inputs.rgb), + ...Object.entries(this.inputs.hsl) + ]; + for (const [key, input] of sliderInputs) { + input.range && input.range + .uiRange() + .on('input', () => { + this.set_color({ [key]: input.range.uiRange('get_value') }); + }); + input.number && input.number + .uiNumberInput() + .on('input', () => { + this.set_color({ [key]: input.number.uiNumberInput('get_value') }); + }) + } + + // Update all inputs from config.COLOR + this.render_selected_color(); + } + + /** + * Changes the config.COLOR variable based on the given input. + * @param {*} definition object contains the value of the color to change: + * hex - set the color as a hex code + * r,g,b - set the color as red, green, blue values [0-255] + * a - set the color alpha [0-255] + * h,s,l - set the color as hue [0-360], saturation [0-100], luminosity [0-100] + * h,s,v - set the color as hue [0-360], saturation [0-100], value [0-100] + */ + set_color(definition) { + let newColor = null; + let newAlpha = null; + let hsl = null; + let hsv = null; + // Set new color by hex code + if ('hex' in definition) { + const hex = '#' + definition.hex.replace(/[^0-9A-F]*/gi, ''); + if (/^\#[0-9A-F]{6}$/gi.test(hex)) { + newColor = '#' + definition.hex.trim().replace(/^\#/, ''); + } + } + // Set new color by rgb + else if ('r' in definition || 'b' in definition || 'g' in definition) { + const previousRgb = Helper.hexToRgb(this.uiType === 'dialog' ? this.COLOR : config.COLOR); + newColor = Helper.rgbToHex( + 'r' in definition ? Math.min(255, Math.max(0, parseInt(definition.r, 10) || 0)) : previousRgb.r, + 'g' in definition ? Math.min(255, Math.max(0, parseInt(definition.g, 10) || 0)) : previousRgb.g, + 'b' in definition ? Math.min(255, Math.max(0, parseInt(definition.b, 10) || 0)) : previousRgb.b + ); + } + // Set new color by hsv + else if ('v' in definition) { + const previousRgb = Helper.hexToRgb(this.uiType === 'dialog' ? this.COLOR : config.COLOR); + const previousHsv = Helper.rgbToHsv(previousRgb.r, previousRgb.g, previousRgb.b); + hsv = { + h: 'h' in definition ? Math.min(360, Math.max(0, parseInt(definition.h, 10) || 0)) / 360 : previousHsv.h, + s: 's' in definition ? Math.min(100, Math.max(0, parseInt(definition.s, 10) || 0)) / 100 : previousHsv.s, + v: 'v' in definition ? Math.min(100, Math.max(0, parseInt(definition.v, 10) || 0)) / 100 : previousHsv.v + }; + newColor = Helper.hsvToHex(hsv.h, hsv.s, hsv.v); + } + // Set new color by hsl + else if ('h' in definition || 's' in definition || 'l' in definition) { + hsl = { + h: ('h' in definition ? Math.min(360, Math.max(0, parseInt(definition.h, 10) || 0)) : parseInt(this.inputs.hsl.h.number.uiNumberInput('get_value'), 10)) / 360, + s: ('s' in definition ? Math.min(100, Math.max(0, parseInt(definition.s, 10) || 0)) : parseInt(this.inputs.hsl.s.number.uiNumberInput('get_value'), 10)) / 100, + l: ('l' in definition ? Math.min(100, Math.max(0, parseInt(definition.l, 10) || 0)) : parseInt(this.inputs.hsl.l.number.uiNumberInput('get_value'), 10)) / 100 + }; + newColor = Helper.hslToHex(hsl.h, hsl.s, hsl.l); + } + // Set new alpha + if ('a' in definition) { + newAlpha = Math.min(255, Math.max(0, parseInt(Math.ceil(definition.a), 10))); + } + // Re-render UI if changes made + if (newColor != null || newAlpha != null) { + if (this.uiType === 'dialog') { + this.COLOR = newColor != null ? newColor : this.COLOR; + this.ALPHA = newAlpha != null ? newAlpha : this.ALPHA; + if (this.colorNotSet) { + this.colorNotSet = false; + $('#dialog_previous_color_sample', this.el)[0].style.background = this.COLOR; + } + } else { + config.COLOR = newColor != null ? newColor : config.COLOR; + config.ALPHA = newAlpha != null ? newAlpha : config.ALPHA; + } + if (hsl && !hsv) { + hsv = Helper.hslToHsv(hsl.h, hsl.s, hsl.l); + } + if (hsv && !hsl) { + hsl = Helper.hsvToHsl(hsv.h, hsv.s, hsv.v); + } + this.render_selected_color({ hsl, hsv }); + } + + if (this.uiType === 'sidebar') { + this.Helper.setCookie('color', config.COLOR); + } + } + + /** + * Renders current color defined in the config to all color fields + * @param {*} options additional options: + * hsl - override for hsl values so it isn't calculated based on rgb (can lose selected hue/saturation otherwise) + * hsv - override for hsv values so it isn't calculated based on rgb (can lose selected hue/saturation otherwise) + */ + render_selected_color(options) { + options = options || {}; + const COLOR = this.uiType === 'dialog' ? this.COLOR : config.COLOR; + const ALPHA = this.uiType === 'dialog' ? this.ALPHA : config.ALPHA; + + this.inputs.sample.css('background', COLOR); + + if (this.uiType !== 'dialog') { + this.inputs.swatches.uiSwatches('set_selected_hex', COLOR); + } + + const hexInput = this.inputs.hex[0]; + hexInput.value = COLOR; + hexInput.setCustomValidity(''); + + const rgb = Helper.hexToRgb(COLOR); + delete rgb.a; + for (let rgbKey in rgb) { + this.inputs.rgb[rgbKey].range.uiRange('set_value', rgb[rgbKey]); + this.inputs.rgb[rgbKey].number.uiNumberInput('set_value', rgb[rgbKey]); + } + this.inputs.rgb.a.range.uiRange('set_value', ALPHA); + this.inputs.rgb.a.number.uiNumberInput('set_value', ALPHA); + + const hsv = options.hsv || Helper.rgbToHsv(rgb.r, rgb.g, rgb.b); + + const hsl = options.hsl || Helper.rgbToHsl(rgb.r, rgb.g, rgb.b); + for (let hslKey in hsl) { + const hslValue = Math.round(hsl[hslKey] * (hslKey === 'h' ? 360 : 100)); + this.inputs.hsl[hslKey].range.uiRange('set_value', hslValue); + this.inputs.hsl[hslKey].number.uiNumberInput('set_value', hslValue); + } + + this.render_ui_deferred({ hsl, hsv }); + } + + /** + * Renders the color gradients in each channel's color range selection. + * This function is throttled due to expensive operations on low-end systems. + * @param {*} options additional options: + * hsl - override for hsl values so it isn't calculated based on rgb (can lose selected hue/saturation otherwise) + * hsv - override for hsv values so it isn't calculated based on rgb (can lose selected hue/saturation otherwise) + */ + render_ui_deferred(options) { + options = options || {}; + const COLOR = this.uiType === 'dialog' ? this.COLOR : config.COLOR; + + // RGB + const rgb = Helper.hexToRgb(COLOR); + delete rgb.a; + for (let rgbKey in rgb) { + const rangeMin = JSON.parse(JSON.stringify(rgb)); + const rangeMax = JSON.parse(JSON.stringify(rgb)); + rangeMin[rgbKey] = 0; + rangeMax[rgbKey] = 255; + this.inputs.rgb[rgbKey].range.uiRange('set_background', + `linear-gradient(to right, ${ Helper.rgbToHex(rangeMin.r, rangeMin.g, rangeMin.b) }, ${ Helper.rgbToHex(rangeMax.r, rangeMax.g, rangeMax.b) })` + ); + } + // A + this.inputs.rgb.a.range.uiRange('set_background', + `linear-gradient(to right, transparent, ${ COLOR })` + ); + // HSV + const hsv = options.hsv || Helper.rgbToHsv(rgb.r, rgb.g, rgb.b); + this.inputs.pickerGradient.uiColorPickerGradient('set_hsv', hsv); + // HSL + const hsl = options.hsl || Helper.rgbToHsl(rgb.r, rgb.g, rgb.b); + // HSL - H + this.inputs.hsl.h.range.uiRange('set_background', + `linear-gradient(to right, ${ + Helper.hex_set_hsl('#ff0000', { s: hsl.s, l: hsl.l }) + } 0%, ${ + Helper.hex_set_hsl('#ffff00', { s: hsl.s, l: hsl.l }) + } 17%, ${ + Helper.hex_set_hsl('#00ff00', { s: hsl.s, l: hsl.l }) + } 33%, ${ + Helper.hex_set_hsl('#00ffff', { s: hsl.s, l: hsl.l }) + } 50%, ${ + Helper.hex_set_hsl('#0000ff', { s: hsl.s, l: hsl.l }) + } 67%, ${ + Helper.hex_set_hsl('#ff00ff', { s: hsl.s, l: hsl.l }) + } 83%, ${ + Helper.hex_set_hsl('#ff0000', { s: hsl.s, l: hsl.l }) + } 100%)` + ); + // HSL - S + let rangeMin = JSON.parse(JSON.stringify(hsl)); + let rangeMax = JSON.parse(JSON.stringify(hsl)); + rangeMin.s = 0; + rangeMax.s = 1; + this.inputs.hsl.s.range.uiRange('set_background', + `linear-gradient(to right, ${ Helper.hslToHex(rangeMin.h, rangeMin.s, rangeMin.l) }, ${ Helper.hslToHex(rangeMax.h, rangeMax.s, rangeMax.l) })` + ); + // HSL - L + let rangeMid = JSON.parse(JSON.stringify(hsl)); + rangeMid.l = 0.5; + this.inputs.hsl.l.range.uiRange('set_background', + `linear-gradient(to right, #000000 0%, ${ Helper.hslToHex(rangeMid.h, rangeMid.s, rangeMid.l) } 50%, #ffffff 100%)` + ); + + // Store swatch values + if (this.uiType === 'sidebar') { + config.swatches.default = this.inputs.swatches.uiSwatches('get_all_hex'); + } + } + +} + +export default GUI_colors_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-details.js b/paintplus/frontend/src/js/core/gui/gui-details.js new file mode 100644 index 0000000..f41860a --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-details.js @@ -0,0 +1,786 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Text_class from './../../tools/text.js'; +import Base_layers_class from "../base-layers"; +import Tools_settings_class from './../../modules/tools/settings.js'; +import Helper_class from './../../libs/helpers.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; + +var template = ` +
+ X + + +
+
+ Y: + + +
+
+ Width: + + +
+
+ Height: + + +
+
+
+ Rotate: + + +
+
+ Opacity: + + +
+
+ Color: + +
+
+
+
+   + +
+
+ Bounds: + +
+
+ Kerning: + +
+ + +
+ Wrap At: + +
+
+ H. Align: + +
+ +
+`; + +/** + * GUI class responsible for rendering selected layer details block on right sidebar + */ +class GUI_details_class { + + constructor() { + this.POP = new Dialog_class(); + this.Text = new Text_class(); + this.Base_layers = new Base_layers_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + this.layer_details_active = false; + this.Tools_translate = new Tools_translate_class(); + this.aspect_locked = true; // Default to locked for image layers + this.aspect_ratio = 1; // Will be calculated from layer dimensions + } + + render_main_details() { + document.getElementById('toggle_details').innerHTML = template; + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, document.getElementById('toggle_details')); + } + this.render_details(true); + } + + render_details(events = false) { + this.render_general('x', events); + this.render_general('y', events); + this.render_general('width', events); + this.render_general('height', events); + this.render_aspect_lock(events); + + this.render_general('rotate', events); + this.render_general('opacity', events); + this.render_color(events); + this.render_reset(events); + + //text - special case + if (config.layer != undefined && config.layer.type == 'text') { + document.getElementById('text_detail_params').style.display = 'block'; + document.getElementById('detail_color').closest('.row').style.display = 'none'; + } + else{ + document.getElementById('text_detail_params').style.display = 'none'; + + if (config.layer != undefined && (config.layer.color === null || config.layer.type == 'image')) { + //hide color + document.getElementById('detail_color').closest('.row').style.display = 'none'; + } + else { + //show color + document.getElementById('detail_color').closest('.row').style.display = 'block'; + } + } + + //add params + this.render_more_parameters(); + + this.render_text(events); + this.render_general_select_param('boundary', events); + this.render_general_select_param('kerning', events); + this.render_general_select_param('text_direction', events); + this.render_general_select_param('wrap', events); + this.render_general_select_param('wrap_direction', events); + this.render_general_select_param('halign', events); + this.render_general_select_param('valign', events); + } + + render_general(key, events) { + var layer = config.layer; + var _this = this; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + if (layer != undefined) { + var target = document.getElementById('detail_' + key); + target.dataset.layer = layer.id; + if (layer[key] == null) { + target.value = ''; + target.disabled = true; + } + else { + var value = layer[key]; + + if(key == 'x' || key == 'y' || key == 'width' || key == 'height'){ + //convert units + value = this.Helper.get_user_unit(value, units, resolution); + } + else { + value = Math.round(value); + } + + //set + target.value = value; + target.disabled = false; + } + } + + if (events) { + //events + var target = document.getElementById('detail_' + key); + if(target == undefined){ + console.log('Error: missing details event target ' + 'detail_' + key); + return; + } + let focus_value = null; + target.addEventListener('focus', function (e) { + focus_value = parseFloat(this.value); + }); + target.addEventListener('blur', function (e) { + if(key == 'x' || key == 'y' || key == 'width' || key == 'height'){ + //convert units + var value = _this.Helper.get_internal_unit(this.value, units, resolution); + } + else { + var value = parseInt(this.value); + } + var layer = _this.Base_layers.get_layer(e.target.dataset.layer); + layer[key] = focus_value; + if (focus_value !== value) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(layer.id, { + [key]: value + }) + ]) + ); + } + }); + target.addEventListener('change', function (e) { + if(key == 'x' || key == 'y' || key == 'width' || key == 'height'){ + //convert units + var value = _this.Helper.get_internal_unit(this.value, units, resolution); + } + else { + var value = parseInt(this.value); + } + + if(this.min != undefined && this.min != '' && value < this.min){ + document.getElementById('detail_opacity').value = value; + value = this.min; + } + if(this.max != undefined && this.min != '' && value > this.max){ + document.getElementById('detail_opacity').value = value; + value = this.max; + } + + config.layer[key] = value; + config.need_render = true; + }); + target.addEventListener('keyup', function (e) { + //for edge.... + if (e.keyCode != 13) { + return; + } + + if(key == 'x' || key == 'y' || key == 'width' || key == 'height'){ + //convert units + var value = _this.Helper.get_internal_unit(this.value, units, resolution); + } + else { + var value = parseInt(this.value); + } + + if(this.min != undefined && this.min != '' && value < this.min){ + document.getElementById('detail_opacity').value = value; + value = this.min; + } + if(this.max != undefined && this.min != '' && value > this.max){ + document.getElementById('detail_opacity').value = value; + value = this.max; + } + + config.layer[key] = value; + config.need_render = true; + }); + } + } + + render_aspect_lock(events) { + var _this = this; + var layer = config.layer; + var lockBtn = document.getElementById('toggle_aspect_lock'); + + if (!lockBtn) return; + + // Update aspect ratio from current layer dimensions + if (layer && layer.width && layer.height) { + this.aspect_ratio = layer.width / layer.height; + } + + // Update button appearance based on lock state + if (this.aspect_locked) { + lockBtn.style.background = '#4a4'; + lockBtn.title = 'Aspect Ratio Locked - Click to Unlock'; + } else { + lockBtn.style.background = ''; + lockBtn.title = 'Aspect Ratio Unlocked - Click to Lock'; + } + + if (events) { + lockBtn.addEventListener('click', function() { + _this.aspect_locked = !_this.aspect_locked; + + // Update aspect ratio when locking + if (_this.aspect_locked && config.layer) { + _this.aspect_ratio = config.layer.width / config.layer.height; + } + + _this.render_aspect_lock(false); + }); + + // Override width change to update height when locked + var widthInput = document.getElementById('detail_width'); + var heightInput = document.getElementById('detail_height'); + + widthInput.addEventListener('input', function(e) { + if (_this.aspect_locked && config.layer) { + var units = _this.Tools_settings.get_setting('default_units'); + var resolution = _this.Tools_settings.get_setting('resolution'); + var newWidth = _this.Helper.get_internal_unit(this.value, units, resolution); + var newHeight = newWidth / _this.aspect_ratio; + + heightInput.value = _this.Helper.get_user_unit(newHeight, units, resolution); + config.layer.height = newHeight; + } + }); + + heightInput.addEventListener('input', function(e) { + if (_this.aspect_locked && config.layer) { + var units = _this.Tools_settings.get_setting('default_units'); + var resolution = _this.Tools_settings.get_setting('resolution'); + var newHeight = _this.Helper.get_internal_unit(this.value, units, resolution); + var newWidth = newHeight * _this.aspect_ratio; + + widthInput.value = _this.Helper.get_user_unit(newWidth, units, resolution); + config.layer.width = newWidth; + } + }); + } + } + + render_general_param(key, events) { + var layer = config.layer; + + if (layer != undefined) { + var target = document.getElementById('detail_param_' + key); + if (layer.params[key] == null) { + target.value = ''; + target.disabled = true; + } + else { + if (typeof layer.params[key] == 'boolean') { + //boolean + if(target.tagName == 'BUTTON'){ + if(layer.params[key]){ + target.classList.add('active'); + } + else{ + target.classList.remove('active'); + } + } + } + else { + //common + target.value = layer.params[key]; + } + target.disabled = false; + } + } + + if (events) { + //events + var target = document.getElementById('detail_param_' + key); + let focus_value = null; + target.addEventListener('focus', function (e) { + focus_value = parseInt(this.value); + }); + target.addEventListener('blur', function (e) { + var value = parseInt(this.value); + config.layer.params[key] = focus_value; + let params_copy = JSON.parse(JSON.stringify(config.layer.params)); + params_copy[key] = value; + if (focus_value !== value) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + params: params_copy + }) + ]) + ); + } + }); + target.addEventListener('change', function (e) { + var value = parseInt(this.value); + config.layer.params[key] = value; + config.need_render = true; + config.need_render_changed_params = true; + + }); + target.addEventListener('click', function (e) { + if (typeof config.layer.params[key] != 'boolean') + return; + this.classList.toggle('active'); + config.layer.params[key] = !config.layer.params[key]; + config.need_render = true; + config.need_render_changed_params = true; + }); + } + } + + render_general_select_param(key, events){ + var layer = config.layer; + + if (layer != undefined) { + var target = document.getElementById('detail_param_' + key); + + if (layer.params[key] == null) { + target.value = ''; + target.disabled = true; + } + else { + if(typeof layer.params[key] == 'object') + target.value = layer.params[key].value; //legacy + else + target.value = layer.params[key]; + target.disabled = false; + } + } + + if (events) { + //events + var target = document.getElementById('detail_param_' + key); + let focus_value = null; + target.addEventListener('focus', function (e) { + focus_value = this.value; + }); + target.addEventListener('blur', function (e) { + var value = this.value; + config.layer.params[key] = focus_value; + let params_copy = JSON.parse(JSON.stringify(config.layer.params)); + params_copy[key] = value; + if (focus_value !== value) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + params: params_copy + }) + ]) + ); + } + }); + target.addEventListener('change', function (e) { + var value = this.value; + config.layer.params[key] = value; + config.need_render = true; + config.need_render_changed_params = true; + }); + } + } + + /** + * item: color + */ + render_color(events) { + var layer = config.layer; + + let $colorInput; + if (events) { + $colorInput = $(document.getElementById('detail_color')).uiColorInput(); + } else { + $colorInput = $(document.getElementById('detail_color')); + } + + if (layer != undefined) { + $colorInput.uiColorInput('set_value', layer.color); + } + + if (events) { + //events + let focus_value = null; + $colorInput.on('focus', function (e) { + focus_value = $colorInput.uiColorInput('get_value'); + }); + $colorInput.on('change', function (e) { + const value = $colorInput.uiColorInput('get_value'); + config.layer.color = focus_value; + if (focus_value !== value) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + color: value + }) + ]) + ); + } + }); + } + } + + /** + * item: size reset button + */ + render_reset(events) { + var layer = config.layer; + + if (layer != undefined) { + //size + if (layer.width_original != null) { + document.getElementById('reset_size').classList.remove('hidden'); + } + else { + document.getElementById('reset_size').classList.add('hidden'); + } + } + + if (events) { + //events + document.getElementById('reset_x').addEventListener('click', function (e) { + if (config.layer.x) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: 0 + }) + ]) + ); + } + }); + document.getElementById('reset_y').addEventListener('click', function (e) { + if (config.layer.y) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + y: 0 + }) + ]) + ); + } + }); + document.getElementById('reset_size').addEventListener('click', function (e) { + if (config.layer.width !== config.layer.width_original + || config.layer.height !== config.layer.height_original) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + width: config.layer.width_original, + height: config.layer.height_original + }) + ]) + ); + } + }); + document.getElementById('reset_rotate').addEventListener('click', function (e) { + if (config.layer.rotate) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + rotate: 0 + }) + ]) + ); + } + }); + document.getElementById('reset_opacity').addEventListener('click', function (e) { + if (config.layer.opacity != 100) { + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + opacity: 100 + }) + ]) + ); + } + }); + } + } + + /** + * item: text + */ + render_text(events) { + if (events) { + //events + document.getElementById('detail_param_text').addEventListener('click', function (e) { + document.querySelector('#tools_container #text').click(); + document.getElementById('text_tool_keyboard_input').focus(); + config.need_render = true; + }); + } + } + + render_more_parameters() { + var _this = this; + var target_id = "parameters_container"; + const itemContainer = document.getElementById(target_id); + + if(this.layer_details_active == true){ + return; + } + + itemContainer.innerHTML = ""; + + if(!config.layer || typeof config.layer.params == 'undefined' || config.layer.type == 'text') { + return; + } + + //find layer parameters settings + var params_config = null; + for (var i in config.TOOLS) { + if (config.TOOLS[i].name == config.layer.type) { + params_config = config.TOOLS[i]; + } + } + if(params_config == null){ + return; + } + + for (var k in params_config.attributes) { + var item = params_config.attributes[k]; + + //hide some fields, in future name should start with underscore + if(params_config.name == 'rectangle' && k == 'square' + || params_config.name == 'ellipse' && k == 'circle' + || params_config.name == 'pencil' && k == 'pressure' + || params_config.name == 'pencil' && k == 'size'){ + continue; + } + + //row + let item_row = document.createElement('div'); + item_row.className = 'row'; + itemContainer.appendChild(item_row); + + //title + var title = k[0].toUpperCase() + k.slice(1); + title = title.replace("_", " "); + let item_title = document.createElement('span'); + item_title.className = 'trn label'; + item_title.innerHTML = title; + item_row.appendChild(item_title); + + //value + if (typeof item == 'boolean' || (typeof item == 'object' && typeof item.value == 'boolean')) { + //boolean - true, false + + const elementInput = document.createElement('button'); + elementInput.type = 'button'; + elementInput.className = 'trn ui_toggle_button'; + elementInput.innerHTML = title; + + elementInput.dataset.key = k; + item_row.appendChild(elementInput); + + let value = config.layer.params[k]; + elementInput.setAttribute('aria-pressed', value); + + //events + elementInput.addEventListener('click', function (e) { + //on leave + let layer = config.layer; + let key = this.dataset.key; + let new_value = elementInput.getAttribute('aria-pressed') !== 'true'; + let params = JSON.parse(JSON.stringify(config.layer.params)); + params[key] = new_value; + + app.State.do_action( + new app.Actions.Update_layer_action(layer.id, { + params: params + }) + ); + }); + } + else if (typeof item == 'number' || (typeof item == 'object' && typeof item.value == 'number')) { + //numbers + + const elementInput = document.createElement('input'); + elementInput.type = 'number'; + elementInput.dataset.key = k; + item_row.appendChild(elementInput); + + let min = 1; + let max = k === 'power' ? 100 : 999; + let step = null; + let value = config.layer.params[k]; + if (typeof item == 'object') { + value = item.value; + if (item.min != null) { + min = item.min; + } + if (item.max != null) { + max = item.max; + } + if (item.step != null) { + step = item.step; + } + } + elementInput.setAttribute('min', min); + elementInput.setAttribute('max', max); + if (item.step != null) { + elementInput.setAttribute('step', step); + } + elementInput.setAttribute('value', config.layer.params[k]); + + //events + let focus_value = null; + elementInput.addEventListener('focus', function (e) { + focus_value = parseFloat(this.value); + _this.layer_details_active = true; + }); + elementInput.addEventListener('blur', function (e) { + //on leave + _this.layer_details_active = false; + let layer = config.layer; + let key = this.dataset.key; + let new_value = parseInt(this.value); + let params = JSON.parse(JSON.stringify(config.layer.params)); + params[key] = new_value; + + if (focus_value !== new_value) { + app.State.do_action( + new app.Actions.Update_layer_action(layer.id, { + params: params + }) + ); + } + }); + elementInput.addEventListener('change', function (e) { + //on change - lots of events here in short time + let key = this.dataset.key; + let new_value = parseInt(this.value); + + config.layer.params[key] = new_value; + config.need_render = true; + }); + } + else if (typeof item == 'string' && item[0] == '#') { + //color + + var elementInput = document.createElement('input'); + elementInput.type = 'color'; + let focus_value = null; + const $colorInput = $(elementInput).uiColorInput({ + id: k, + value: item + }) + .on('change', () => { + let layer = config.layer; + let key = $colorInput.uiColorInput('get_id'); + let new_value = $colorInput.uiColorInput('get_value'); + let params = JSON.parse(JSON.stringify(config.layer.params)); + params[key] = new_value; + + app.State.do_action( + new app.Actions.Update_layer_action(layer.id, { + params: params + }) + ); + }); + $colorInput.uiColorInput('set_value', config.layer.params[k]); + + item_row.appendChild($colorInput[0]); + } + else { + alertify.error('Error: unsupported attribute type:' + typeof item + ', ' + k); + } + } + } + +} + +export default GUI_details_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-information.js b/paintplus/frontend/src/js/core/gui/gui-information.js new file mode 100644 index 0000000..a9bf7ab --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-information.js @@ -0,0 +1,105 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../../config.js'; +import Base_layers_class from './../base-layers.js'; +import Tools_settings_class from './../../modules/tools/settings.js'; +import Helper_class from './../../libs/helpers.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; + +var template = ` + Size: + - + +
+ Mouse: + - + +
+ Resolution: + - +`; + +/** + * GUI class responsible for rendering information block on right sidebar + */ +class GUI_information_class { + + constructor(ctx) { + this.Base_layers = new Base_layers_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + this.Tools_translate = new Tools_translate_class(); + this.last_width = null; + this.last_height = null; + this.units = this.Tools_settings.get_setting('default_units'); + this.resolution = this.Tools_settings.get_setting('resolution'); + } + + render_main_information() { + document.getElementById('toggle_info').innerHTML = template; + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, document.getElementById('toggle_info')); + } + this.set_events(); + this.show_size(); + } + + set_events() { + var _this = this; + var target = document.getElementById('mouse_info_mouse'); + + //show width and height + //should use canvas resize API in future + document.addEventListener('mousemove', function (e) { + _this.show_size(); + }, false); + + //show current mouse position + document.getElementById('canvas_minipaint').addEventListener('mousemove', function (e) { + var global_pos = _this.Base_layers.get_world_coords(e.offsetX, e.offsetY); + var mouse_x = Math.ceil(global_pos.x); + var mouse_y = Math.ceil(global_pos.y); + + mouse_x = _this.Helper.get_user_unit(mouse_x, _this.units, _this.resolution); + mouse_y = _this.Helper.get_user_unit(mouse_y, _this.units, _this.resolution); + + target.innerHTML = mouse_x + ', ' + mouse_y; + }, false); + } + + update_units(){ + this.units = this.Tools_settings.get_setting('default_units'); + this.resolution = this.Tools_settings.get_setting('resolution'); + this.show_size(true); + } + + show_size(force) { + if(force == undefined && this.last_width == config.WIDTH && this.last_height == config.HEIGHT) { + return; + } + + var width = this.Helper.get_user_unit(config.WIDTH, this.units, this.resolution); + var height = this.Helper.get_user_unit(config.HEIGHT, this.units, this.resolution); + + document.getElementById('mouse_info_size').innerHTML = width + ' x ' + height; + + var resolution = this.Tools_settings.get_setting('resolution'); + document.getElementById('mouse_info_resolution').innerHTML = resolution; + + //show units + var default_units = this.Tools_settings.get_setting('default_units_short'); + var targets = document.querySelectorAll('.id-mouse_info_units'); + for (var i = 0; i < targets.length; i++) { + targets[i].innerHTML = default_units; + } + + this.last_width = config.WIDTH; + this.last_height = config.HEIGHT; + } + +} + +export default GUI_information_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-layers.js b/paintplus/frontend/src/js/core/gui/gui-layers.js new file mode 100644 index 0000000..b00cdf2 --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-layers.js @@ -0,0 +1,341 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import Layer_rename_class from './../../modules/layer/rename.js'; +import Effects_browser_class from './../../modules/effects/browser.js'; +import Layer_duplicate_class from './../../modules/layer/duplicate.js'; +import Layer_raster_class from './../../modules/layer/raster.js'; +import Layer_scale_class from './../../modules/layer/scale.js'; +import Layer_merge_class from './../../modules/layer/merge.js'; +import Layer_flatten_class from './../../modules/layer/flatten.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; + +var template = ` + + + + + + + + +
+
+`; + +/** + * GUI class responsible for rendering layers on right sidebar + */ +class GUI_layers_class { + + constructor(ctx) { + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.Layer_rename = new Layer_rename_class(); + this.Effects_browser = new Effects_browser_class(); + this.Layer_duplicate = new Layer_duplicate_class(); + this.Layer_raster = new Layer_raster_class(); + this.Layer_scale = new Layer_scale_class(); + this.Layer_merge = new Layer_merge_class(); + this.Layer_flatten = new Layer_flatten_class(); + this.Tools_translate = new Tools_translate_class(); + this.contextMenuLayerId = null; + } + + render_main_layers() { + document.getElementById('layers_base').innerHTML = template; + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, document.getElementById('layers_base')); + } + this.render_layers(); + this.set_events(); + } + + set_events() { + var _this = this; + + document.getElementById('layers_base').addEventListener('click', function (event) { + var target = event.target; + if (target.id == 'insert_layer') { + //new layer + app.State.do_action( + new app.Actions.Insert_layer_action() + ); + } + else if (target.id == 'layer_duplicate') { + //duplicate + _this.Layer_duplicate.duplicate(); + } + else if (target.id == 'layer_raster') { + //raster + _this.Layer_raster.raster(); + } + else if (target.id == 'layer_scale') { + //scale + _this.Layer_scale.scale(); + } + else if (target.id == 'layer_up') { + //move layer up + app.State.do_action( + new app.Actions.Reorder_layer_action(config.layer.id, 1) + ); + } + else if (target.id == 'layer_down') { + //move layer down + app.State.do_action( + new app.Actions.Reorder_layer_action(config.layer.id, -1) + ); + } + else if (target.id == 'visibility') { + //change visibility + return app.State.do_action( + new app.Actions.Toggle_layer_visibility_action(target.dataset.id) + ); + } + else if (target.id == 'delete') { + //delete layer + app.State.do_action( + new app.Actions.Delete_layer_action(target.dataset.id) + ); + } + else if (target.id == 'layer_name') { + //select layer + if (target.dataset.id == config.layer.id) + return; + app.State.do_action( + new app.Actions.Select_layer_action(target.dataset.id) + ); + } + else if (target.id == 'delete_filter') { + //delete filter + app.State.do_action( + new app.Actions.Delete_layer_filter_action(target.dataset.pid, target.dataset.id) + ); + } + else if (target.id == 'filter_name') { + //edit filter + var effects = _this.Effects_browser.get_effects_list(); + var key = target.dataset.filter.toLowerCase(); + for (var i in effects) { + if(effects[i].title.toLowerCase() == key){ + _this.Base_layers.select(target.dataset.pid); + var function_name = _this.Effects_browser.get_function_from_path(key); + effects[i].object[function_name](target.dataset.id); + } + } + } + }); + + document.getElementById('layers_base').addEventListener('dblclick', function (event) { + var target = event.target; + if (target.id == 'layer_name') { + //rename layer + _this.Layer_rename.rename(target.dataset.id); + } + }); + + // Right-click context menu for layers + document.getElementById('layers_base').addEventListener('contextmenu', function (event) { + var target = event.target; + + // Check if right-clicked on a layer item + if (target.id == 'layer_name' || target.closest('.item')) { + event.preventDefault(); + + var layerId = target.dataset.id || target.closest('.item').querySelector('[data-id]').dataset.id; + _this.showContextMenu(event.clientX, event.clientY, layerId); + } + }); + + // Hide context menu when clicking elsewhere + document.addEventListener('click', function (event) { + _this.hideContextMenu(); + }); + + } + + /** + * Show context menu for layer + */ + showContextMenu(x, y, layerId) { + var _this = this; + this.contextMenuLayerId = layerId; + + // Select the layer first + if (layerId != config.layer.id) { + app.State.do_action( + new app.Actions.Select_layer_action(layerId) + ); + } + + var menuItems = [ + { label: 'Rename', action: 'rename' }, + { label: 'Duplicate', action: 'duplicate' }, + { label: 'Delete', action: 'delete' }, + { label: '---' }, + { label: 'Move Up', action: 'move_up' }, + { label: 'Move Down', action: 'move_down' }, + { label: '---' }, + { label: 'Scale Layer...', action: 'scale' }, + { label: 'Convert to Raster', action: 'raster' }, + { label: '---' }, + { label: 'Merge Down', action: 'merge' }, + { label: 'Flatten All', action: 'flatten' }, + ]; + + var menu = document.getElementById('layer_context_menu'); + var html = '
    '; + + for (var i = 0; i < menuItems.length; i++) { + var item = menuItems[i]; + if (item.label === '---') { + html += '
  • '; + } else { + html += '
  • ' + item.label + '
  • '; + } + } + + html += '
'; + menu.innerHTML = html; + menu.style.display = 'block'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + + // Add click handlers to menu items + menu.querySelectorAll('li[data-action]').forEach(function(item) { + item.addEventListener('click', function(e) { + e.stopPropagation(); + _this.handleContextMenuAction(this.dataset.action); + _this.hideContextMenu(); + }); + }); + } + + /** + * Hide context menu + */ + hideContextMenu() { + var menu = document.getElementById('layer_context_menu'); + if (menu) { + menu.style.display = 'none'; + } + } + + /** + * Handle context menu action + */ + handleContextMenuAction(action) { + var layerId = this.contextMenuLayerId; + + switch (action) { + case 'rename': + this.Layer_rename.rename(layerId); + break; + case 'duplicate': + this.Layer_duplicate.duplicate(); + break; + case 'delete': + app.State.do_action( + new app.Actions.Delete_layer_action(layerId) + ); + break; + case 'move_up': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, 1) + ); + break; + case 'move_down': + app.State.do_action( + new app.Actions.Reorder_layer_action(layerId, -1) + ); + break; + case 'scale': + this.Layer_scale.scale(); + break; + case 'raster': + this.Layer_raster.raster(); + break; + case 'merge': + this.Layer_merge.merge(); + break; + case 'flatten': + this.Layer_flatten.flatten(); + break; + } + } + + /** + * renders layers list + */ + render_layers() { + var target_id = 'layers'; + var layers = config.layers.concat().sort( + //sort function + (a, b) => b.order - a.order + ); + + document.getElementById(target_id).innerHTML = ''; + var html = ''; + + if (config.layer) { + for (var i in layers) { + var value = layers[i]; + var class_extra = ''; + if(value.composition === 'source-atop'){ + class_extra += ' shorter'; + } + if (value.id == config.layer.id){ + class_extra += ' active'; + } + + html += '
'; + if (value.visible == true) + html += ' '; + else + html += ' '; + html += ' '; + + if(value.composition === 'source-atop'){ + html += ' '; + } + + var layer_title = this.Helper.escapeHtml(value.name); + + html += ' '; + html += '
'; + html += '
'; + + //show filters + if (layers[i].filters.length > 0) { + html += '
'; + for (var j in layers[i].filters) { + var filter = layers[i].filters[j]; + var title = this.Helper.ucfirst(filter.name); + title = title.replace(/-/g, ' '); + + html += '
'; + html += ' '; + html += ' ' + title + ''; + html += '
'; + html += '
'; + } + html += '
'; + } + } + } + + //register + document.getElementById(target_id).innerHTML = html; + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, document.getElementById(target_id)); + } + } +} + +export default GUI_layers_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-menu.js b/paintplus/frontend/src/js/core/gui/gui-menu.js new file mode 100644 index 0000000..f831c4c --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-menu.js @@ -0,0 +1,419 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../../config.js'; +import menuDefinition from './../../config-menu.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; + +/** + * class responsible for rendering main menu + */ +class GUI_menu_class { + + constructor() { + this.eventSubscriptions = {}; + this.dropdownMaxHeightMargin = 15; + this.menuContainer = null; + this.menuBarNode = null; + this.lastFocusedMenuBarLink = 0; + this.dropdownStack = []; + + this.Tools_translate = new Tools_translate_class(); + } + + render_main() { + this.menuContainer = document.getElementById('main_menu'); + + let menuTemplate = ''; + + this.menuContainer.innerHTML = menuTemplate; + this.menuBarNode = this.menuContainer.querySelector('[role="menubar"]'); + + this.menuContainer.addEventListener('click', (event) => { return this.on_click_menu(event); }, true); + this.menuContainer.addEventListener('keydown', (event) => { return this.on_key_down_menu(event); }, true); + this.menuBarNode.addEventListener('focus', (event) => { return this.on_focus_menu_bar(event); }); + this.menuBarNode.addEventListener('blur', (event) => { return this.on_blur_menu_bar(event); }); + this.menuBarNode.querySelectorAll('a').forEach((link) => { + link.addEventListener('focus', (event) => { return this.on_focus_menu_bar_link(event); }); + }); + document.body.addEventListener('mousedown', (event) => { return this.on_mouse_down_body(event); }, true); + document.body.addEventListener('touchstart', (event) => { return this.on_mouse_down_body(event); }, true); + window.addEventListener('resize', (event) => { return this.on_resize_window(event); }, true); + + document.body.classList.add('loaded'); + + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, this.menuContainer); + } + } + + on(eventName, callback) { + if (!this.eventSubscriptions[eventName]) { + this.eventSubscriptions[eventName] = []; + } + if (!this.eventSubscriptions[eventName].includes(callback)) { + this.eventSubscriptions[eventName].push(callback); + } + } + + emit(eventName, payload, object) { + if (this.eventSubscriptions[eventName]) { + for (let callback of this.eventSubscriptions[eventName]) { + callback(payload, object); + } + } + } + + generate_menu_bar_item_template(definition, index) { + return ` +
  • + +
  • + `.trim(); + } + + generate_menu_dropdown_item_template(definition, level, index) { + if (definition.divider) { + return ` +
  • +
    +
  • + `.trim(); + } else { + return ` +
  • + + ${ definition.name }${ definition.ellipsis ? ' ...' : '' } + ${ !!definition.shortcut ? ` + Shortcut Key: ${ definition.shortcut } + ` : `` } + +
  • + `.trim(); + } + } + + on_mouse_down_body(event) { + const target = event.touches && event.touches.length > 0 ? event.touches[0].target : event.target; + + // Clicked outside of menu; close dropdowns. + if (target && !this.menuContainer.contains(target)) { + this.close_child_dropdowns(0); + } + } + + on_focus_menu_bar(event) { + if (document.activeElement === this.menuBarNode) { + let lastFocusedLink = this.menuBarNode.querySelector(`[data-index="${ this.lastFocusedMenuBarLink }"]`); + if (!lastFocusedLink) { + lastFocusedLink = this.menuBarNode.querySelector('a'); + } + lastFocusedLink.focus(); + } + } + + on_focus_menu_bar_link(event) { + this.lastFocusedMenuBarLink = parseInt(event.target.getAttribute('data-index'), 10) || 0; + } + + on_blur_menu_bar(event) { + // TODO + } + + on_key_down_menu(event) { + const key = event.key; + const activeElement = document.activeElement; + + if (activeElement && activeElement.tagName === 'A') { + const linkLevel = parseInt(activeElement.getAttribute('data-level'), 10) || 0; + const linkIndex = parseInt(activeElement.getAttribute('data-index'), 10) || 0; + const menuParent = activeElement.closest('ul'); + if (linkLevel === 0) { + if (['Right', 'ArrowRight'].includes(event.key)) { + let nextLink = menuParent.querySelector(`[data-index="${ linkIndex + 1 }"]`); + if (!nextLink) { + nextLink = menuParent.querySelector(`[data-index="0"]`); + } + nextLink.focus(); + } + else if (['Left', 'ArrowLeft'].includes(event.key)) { + let previousLink = menuParent.querySelector(`[data-index="${ linkIndex - 1 }"]`); + if (!previousLink) { + previousLink = menuParent.querySelector(`[data-index="${ menuParent.querySelectorAll('[data-index]').length - 1 }"]`); + } + previousLink.focus(); + } + else if (['Down', 'ArrowDown'].includes(event.key)) { + if (activeElement.getAttribute('aria-haspopup') === 'true') { + event.preventDefault(); + activeElement.click(); + } + } + else if (event.key === 'Home') { + menuParent.querySelector(`[data-index="0"]`).focus(); + } + else if (event.key === 'End') { + menuParent.querySelector(`[data-index="${ menuParent.querySelectorAll('[data-index]').length - 1 }"]`).focus(); + } + else if ([' ', 'Enter'].includes(event.key)) { + event.preventDefault(); + activeElement.click(); + } + } else { + if (['Up', 'ArrowUp'].includes(event.key)) { + event.preventDefault(); + let previousLink = menuParent.querySelector(`[data-index="${ linkIndex - 1 }"]`); + if (!previousLink) { + previousLink = menuParent.querySelector(`[data-index="${ linkIndex - 2 }"]`); // Skip dividers + } + if (!previousLink) { + previousLink = menuParent.querySelector(`[data-index="${ this.dropdownStack[linkLevel - 1].children.length - 1 }"]`); + } + previousLink.focus(); + } + else if (['Down', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + let nextLink = menuParent.querySelector(`[data-index="${ linkIndex + 1 }"]`); + if (!nextLink) { + nextLink = menuParent.querySelector(`[data-index="${ linkIndex + 2 }"]`); // Skip dividers + } + if (!nextLink) { + nextLink = menuParent.querySelector(`[data-index="0"]`); + } + nextLink.focus(); + } + else if (['Right', 'ArrowRight'].includes(event.key)) { + if (activeElement.getAttribute('aria-haspopup') === 'true') { + activeElement.click(); + } + else if (this.dropdownStack.length > 1) { + const opener = this.dropdownStack[linkLevel - 1].opener; + opener.click(); + opener.focus(); + } + else { + const menuBarLinkIndex = parseInt(this.dropdownStack[0].opener.getAttribute('data-index'), 10) || 0; + let nextLink = this.menuBarNode.querySelector(`[data-index="${ menuBarLinkIndex + 1 }"]`); + if (!nextLink) { + nextLink = this.menuBarNode.querySelector(`[data-index="0"]`); + } + nextLink.click(); + } + } + else if (['Left', 'ArrowLeft'].includes(event.key)) { + if (this.dropdownStack.length > 1) { + const opener = this.dropdownStack[linkLevel - 1].opener; + opener.click(); + opener.focus(); + } else { + const menuBarLinkIndex = parseInt(this.dropdownStack[0].opener.getAttribute('data-index'), 10) || 0; + let previousLink = this.menuBarNode.querySelector(`[data-index="${ menuBarLinkIndex - 1 }"]`); + if (!previousLink) { + previousLink = this.menuBarNode.querySelector(`[data-index="${ this.menuBarNode.querySelectorAll('[data-index]').length - 1 }"]`); + } + previousLink.click(); + } + } + else if (event.key === 'Home') { + menuParent.querySelector(`[data-index="0"]`).focus(); + } + else if (event.key === 'End') { + menuParent.querySelector(`[data-index="${ this.dropdownStack[linkLevel - 1].children.length - 1 }"]`).focus(); + } + else if ([' ', 'Enter'].includes(event.key)) { + event.preventDefault(); + activeElement.click(); + } + else if (['Esc', 'Escape'].includes(event.key)) { + const opener = this.dropdownStack[linkLevel - 1].opener; + opener.click(); + opener.focus(); + } + else if (event.key === 'Tab') { + this.close_child_dropdowns(0); + } + } + } + } + + on_click_menu(event) { + const target = event.target.closest('a'); + + // Any link in the menu is clicked. + if (target && target.tagName === 'A') { + const hasPopup = target.getAttribute('aria-haspopup') === 'true'; + if (hasPopup) { + this.toggle_dropdown(target, event.isTrusted); + } else { + this.trigger_link(target); + } + } else { + this.close_child_dropdowns(0); + } + } + + on_resize_window(event) { + if (this.dropdownStack.length > 0) { + this.position_dropdowns(); + } + } + + toggle_dropdown(opener, isTrusted) { + const linkLevel = parseInt(opener.getAttribute('data-level'), 10) || 0; + const linkIndex = parseInt(opener.getAttribute('data-index'), 10) || 0; + if (opener.getAttribute('aria-expanded') === 'true') { + this.close_child_dropdowns(linkLevel); + } else { + const parentList = opener.closest('ul'); + parentList.querySelectorAll('a').forEach((item) => { + item.setAttribute('aria-expanded', 'false'); + }); + opener.setAttribute('aria-expanded', true); + this.create_dropdown(opener, linkLevel, linkIndex, !isTrusted); + } + } + + trigger_link(link) { + const level = parseInt(link.getAttribute('data-level'), 10) || 0; + const index = parseInt(link.getAttribute('data-index'), 10) || 0; + + // Find link definition + let children = menuDefinition; + for (let i = 0; i < level; i++) { + const childIndex = this.dropdownStack[i] != null ? this.dropdownStack[i].index : index; + children = children[childIndex].children; + } + let definition = children[index]; + + // Close the dropdown + this.close_child_dropdowns(0); + + // Emit callback events for triggered links + if (definition.target) { + this.emit('select_target', definition.target, definition); + } + else if (definition.href) { + this.emit('select_href', definition.href, null); + } + } + + close_child_dropdowns(level) { + for (let i = this.dropdownStack.length - 1; i >= 0; i--) { + if (i >= level) { + this.dropdownStack[i].element.parentNode.removeChild(this.dropdownStack[i].element); + this.dropdownStack[i].opener.setAttribute('aria-expanded', false); + } + } + this.dropdownStack = this.dropdownStack.slice(0, level); + } + + create_dropdown(opener, level, index, focusAfterCreation) { + this.close_child_dropdowns(level); + + // Find child list in the menu definition + let children = menuDefinition; + for (let i = 0; i <= level; i++) { + const childIndex = this.dropdownStack[i] != null ? this.dropdownStack[i].index : index; + children = children[childIndex].children; + } + + // Create the dropdown element, place it in DOM & position it + let dropdownElement = document.createElement('ul'); + dropdownElement.className = 'menu_dropdown'; + dropdownElement.role = 'menu'; + dropdownElement.tabIndex = 0; + dropdownElement.setAttribute('aria-labelledby', 'main_menu_' + level + '_' + index); + let dropdownTemplate = ''; + for (let i = 0; i < children.length; i++) { + dropdownTemplate += this.generate_menu_dropdown_item_template(children[i], level + 1, i); + } + dropdownElement.innerHTML = dropdownTemplate; + + this.menuContainer.appendChild(dropdownElement); + + if (config.LANG != 'en') { + this.Tools_translate.translate(config.LANG, this.menuContainer); + } + + if (focusAfterCreation) { + dropdownElement.querySelector('a').focus(); + } + + this.dropdownStack.push({ + children, + opener, + index, + element: dropdownElement + }); + + this.position_dropdowns(); + } + + position_dropdowns() { + const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); + const vh = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); + + let topNavHeight = 0; + for (let level = 0; level < this.dropdownStack.length; level++) { + const dropdownElement = this.dropdownStack[level].element; + const openerRect = this.dropdownStack[level].opener.getBoundingClientRect(); + + topNavHeight = openerRect.height; + const dropdownMaxHeight = vh - topNavHeight - this.dropdownMaxHeightMargin; + dropdownElement.style.maxHeight = dropdownMaxHeight + 'px'; + const dropdownRect = dropdownElement.getBoundingClientRect(); + + if (level === 0) { + dropdownElement.style.top = (openerRect.y + openerRect.height) + 'px'; + + let left = openerRect.x; + if (left + dropdownRect.width > vw) { + left = openerRect.x + openerRect.width - dropdownRect.width; + } + if (left + dropdownRect.width > vw) { + left = vw - dropdownRect.width; + } + if (left < 0) { + left = 0; + } + dropdownElement.style.left = left + 'px'; + } else { + let top = openerRect.y; + if (top + dropdownRect.height > vh - this.dropdownMaxHeightMargin) { + top = vh - this.dropdownMaxHeightMargin - dropdownRect.height; + } + dropdownElement.style.top = top + 'px'; + + let left = openerRect.x + openerRect.width + 1; + if (left + dropdownRect.width > vw) { + left = openerRect.x - dropdownRect.width - 1; + } + if (left < 0) { + if (openerRect.x + (openerRect.width / 2) > vw / 2) { + left = 1; + } else { + left = vw - dropdownRect.width - 1; + if (left < 0) { + left = 1; + } + } + } + dropdownElement.style.left = left + 'px'; + } + } + } + +} + +export default GUI_menu_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-preview.js b/paintplus/frontend/src/js/core/gui/gui-preview.js new file mode 100644 index 0000000..8316da3 --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-preview.js @@ -0,0 +1,352 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import config from './../../config.js'; +import Base_layers_class from './../base-layers.js'; + +var instance = null; + +var template = ` +
    +
    + +
    +
    +
    + + + + +
    + +
    +`; + +/** + * GUI class responsible for rendering preview on right sidebar + */ +class GUI_preview_class { + + constructor(GUI_class) { + //singleton + if (instance) { + return instance; + } + instance = this; + document.getElementById('toggle_preview').innerHTML = template; + + // preview mini window size on right sidebar + this.PREVIEW_SIZE = {w: 176, h: 100}; + + this.canvas_offset = {x: 0, y: 0}; + + this.zoom_data = { + x: 0, + y: 0, + move_pos: null, + }; + + this.mouse_pressed = false; + this.canvas_preview = null; + if (GUI_class != undefined) { + this.GUI = GUI_class; + } + this.Base_layers = new Base_layers_class(); + } + + render_main_preview() { + this.canvas_preview = document.getElementById("canvas_preview") + .getContext("2d"); + + this.prepare_canvas(); + config.need_render = true; + this.set_events(); + } + + set_events() { + var _this = this; + var is_touch = false; + + document.addEventListener('mousedown', function (e) { + _this.mouse_pressed = true; + }, false); + document.addEventListener('mouseup', function (e) { + _this.mouse_pressed = false; + }, false); + document.addEventListener('touchstart', function (e) { + _this.mouse_pressed = true; + }, false); + document.addEventListener('touchend', function (e) { + _this.mouse_pressed = false; + }, false); + document.getElementById('zoom_range').addEventListener('input', function (e) { + _this.set_center_zoom(); + _this.zoom(this.value); + }, false); + document.getElementById('zoom_range').addEventListener('change', function (e) { + //IE11 + if (this.value != config.ZOOM * 100) { + _this.set_center_zoom(); + _this.zoom(this.value); + } + }, false); + document.getElementById('zoom_less').addEventListener('click', function (e) { + _this.set_center_zoom(); + _this.zoom(-1); + }, false); + document.getElementById('zoom_100').addEventListener('click', function (e) { + _this.zoom(100); + }, false); + document.getElementById('zoom_more').addEventListener('click', function (e) { + _this.set_center_zoom(); + _this.zoom(+1); + }, false); + document.getElementById('zoom_fit').addEventListener('click', function (e) { + _this.zoom_auto(); + }, false); + document.getElementById('main_wrapper').addEventListener('wheel', function (e) { + //zoom with mouse scroll + e.preventDefault(); + _this.zoom_data.x = e.offsetX; + _this.zoom_data.y = e.offsetY; + var delta = Math.max(-1, Math.min(1, (e.wheelDelta || -e.detail || -e.deltaY))); + if (delta > 0) + _this.zoom(+1, e); + else + _this.zoom(-1, e); + }, false); + window.addEventListener('resize', function (e) { + //resize + config.need_render = true; + }, false); + document.getElementById("canvas_preview").addEventListener('mousedown', function (e) { + if(is_touch) + return; + _this.set_zoom_position(e); + }, false); + document.getElementById("canvas_preview").addEventListener('mousemove', function (e) { + if(is_touch) + return; + if (_this.mouse_pressed == false) + return; + _this.set_zoom_position(e); + }, false); + + document.getElementById("canvas_preview").addEventListener('touchstart', function (e) { + is_touch = true; + + //calc canvas position offset + var bodyRect = document.body.getBoundingClientRect(); + var canvas_el = document.getElementById("canvas_preview").getBoundingClientRect(); + _this.canvas_offset.x = canvas_el.left - bodyRect.left; + _this.canvas_offset.y = canvas_el.top - bodyRect.top; + + //change zoom offset + _this.set_zoom_position(e); + }); + document.getElementById("canvas_preview").addEventListener('touchmove', function (e) { + //change zoom offset + if (_this.mouse_pressed == false) + return; + _this.set_zoom_position(e); + }); + } + + prepare_canvas() { + this.canvas_preview.webkitImageSmoothingEnabled = false; + this.canvas_preview.msImageSmoothingEnabled = false; + this.canvas_preview.imageSmoothingEnabled = false; + this.GUI.render_canvas_background('canvas_preview', 8); + } + + render_preview_active_zone() { + if (this.canvas_preview == undefined) { + this.canvas_preview = document.getElementById("canvas_preview") + .getContext("2d"); + } + + //active zone + var visible_w = config.visible_width / config.ZOOM; + var visible_h = config.visible_height / config.ZOOM; + + var mini_rect_w = this.PREVIEW_SIZE.w * visible_w / config.WIDTH; + var mini_rect_h = this.PREVIEW_SIZE.h * visible_h / config.HEIGHT; + + var start_pos = this.Base_layers.get_world_coords(0, 0); + var mini_rect_x = start_pos.x / config.WIDTH * this.PREVIEW_SIZE.w; + var mini_rect_y = start_pos.y / config.HEIGHT * this.PREVIEW_SIZE.h; + + //validate + mini_rect_x = Math.max(0, mini_rect_x); + mini_rect_y = Math.max(0, mini_rect_y); + mini_rect_w = Math.min(this.PREVIEW_SIZE.w - 1, mini_rect_w); + mini_rect_h = Math.min(this.PREVIEW_SIZE.h - 1, mini_rect_h); + if (mini_rect_x + mini_rect_w > this.PREVIEW_SIZE.w) + mini_rect_x = this.PREVIEW_SIZE.w - mini_rect_w; + if (mini_rect_y + mini_rect_h > this.PREVIEW_SIZE.h) + mini_rect_y = this.PREVIEW_SIZE.h - mini_rect_h; + + if (mini_rect_x == 0 && mini_rect_y == 0 && mini_rect_w == this.PREVIEW_SIZE.w - 1 + && mini_rect_h == this.PREVIEW_SIZE.h - 1) { + //everything is visible + return; + } + + //draw selected area in preview canvas + this.canvas_preview.lineWidth = 1; + this.canvas_preview.beginPath(); + this.canvas_preview.rect( + Math.round(mini_rect_x) + 0.5, + Math.round(mini_rect_y) + 0.5, + mini_rect_w, + mini_rect_h + ); + this.canvas_preview.fillStyle = "rgba(0, 255, 0, 0.3)"; + this.canvas_preview.strokeStyle = "#00ff00"; + this.canvas_preview.fill(); + this.canvas_preview.stroke(); + } + + async zoom(recalc) { + if (recalc != undefined) { + //zoom-in or zoom-out + if (recalc == 1 || recalc == -1) { + //fix + if (config.ZOOM > 1 && config.ZOOM < 1.5) { + config.ZOOM = 1; + } + if (config.ZOOM > 0.9 && config.ZOOM < 1) { + config.ZOOM = 1; + } + + //calc step + if (recalc < 0) { + //down + if (config.ZOOM > 3) { + //infinity -> 300% + config.ZOOM -= 1; + } + else if (config.ZOOM > 1) { + //300% -> 100% + config.ZOOM -= 0.5; + } + else if (config.ZOOM > 0.1) { + //100% -> 10% + config.ZOOM -= 0.1; + } + else { + //10% -> 1% + config.ZOOM -= 0.01; + } + } + else { + //up + if (config.ZOOM < 0.1) { + //1% -> 10% + config.ZOOM += 0.01; + } + else if (config.ZOOM < 1) { + //10% -> 100% + config.ZOOM += 0.1; + } + else if (config.ZOOM < 3) { + //100% -> 300% + config.ZOOM += 0.5; + } + else { + //300% -> more + config.ZOOM += 1; + } + } + } + else { + //zoom using exact value + config.ZOOM = recalc / 100; + } + config.ZOOM = Math.round(config.ZOOM * 100) / 100; + config.ZOOM = Math.max(config.ZOOM, 0.01); + config.ZOOM = Math.min(config.ZOOM, 500); + } + + document.getElementById("zoom_100").innerHTML = Math.round(config.ZOOM * 100) + '%'; + document.getElementById("zoom_range").value = (config.ZOOM * 100); + + config.need_render = true; + this.GUI.prepare_canvas(); + + //sleep after last image import, it maybe not be finished yet + await new Promise(r => setTimeout(r, 10)); + + return true; + } + + zoom_auto(only_increase) { + var container = document.getElementById('main_wrapper'); + var page_w = container.clientWidth; + var page_h = container.clientHeight; + + var best_width = page_w / config.WIDTH; + var best_height = page_h / config.HEIGHT; + var best_zoom = null; + + best_zoom = Math.min(best_width, best_height); + + if (only_increase != undefined && best_zoom > 1) { + return false; + } + + this.zoom(Math.min(best_width, best_height) * 100); + } + + set_center_zoom() { + this.zoom_data.x = config.visible_width / 2; + this.zoom_data.y = config.visible_height / 2; + } + + set_zoom_position(event) { + var mouse_x = event.offsetX; + var mouse_y = event.offsetY; + if (event.changedTouches) { + //touch events + event = event.changedTouches[0]; + + mouse_x = event.pageX - this.canvas_offset.x; + mouse_y = event.pageY - this.canvas_offset.y; + } + + var visible_w = config.visible_width / config.ZOOM; + var visible_h = config.visible_height / config.ZOOM; + var mini_w = this.PREVIEW_SIZE.w * visible_w / config.WIDTH; + var mini_h = this.PREVIEW_SIZE.h * visible_h / config.HEIGHT; + + var change_x = (mouse_x - mini_w / 2) / this.PREVIEW_SIZE.w * config.WIDTH; + var change_y = (mouse_y - mini_h / 2) / this.PREVIEW_SIZE.h * config.HEIGHT; + + var zoom_data = this.zoom_data; + zoom_data.move_pos = {}; + zoom_data.move_pos.x = change_x; + zoom_data.move_pos.y = change_y; + + config.need_render = true; + } + + /** + * moves visible area to new position. + * + * @param {int} x global offset + * @param {int} y global offset + */ + zoom_to_position(x, y) { + var zoom_data = this.zoom_data; + zoom_data.move_pos = {}; + zoom_data.move_pos.x = parseInt(x); + zoom_data.move_pos.y = parseInt(y); + + config.need_render = true; + } + +} + +export default GUI_preview_class; diff --git a/paintplus/frontend/src/js/core/gui/gui-tools.js b/paintplus/frontend/src/js/core/gui/gui-tools.js new file mode 100644 index 0000000..a60a553 --- /dev/null +++ b/paintplus/frontend/src/js/core/gui/gui-tools.js @@ -0,0 +1,390 @@ +/* + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Helper_class from './../../libs/helpers.js'; +import Tools_translate_class from './../../modules/tools/translate.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Base_gui_class from '../base-gui.js'; + +var instance = null; + +/** + * GUI class responsible for rendering left sidebar tools + */ +class GUI_tools_class { + + constructor(GUI_class) { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Helper = new Helper_class(); + this.Tools_translate = new Tools_translate_class(); + this.Base_gui = new Base_gui_class(); + + //active tool + this.active_tool = 'brush'; + this.tools_modules = {}; + } + + load_plugins() { + var _this = this; + var ctx = document.getElementById('canvas_minipaint').getContext("2d"); + var plugins_context = require.context("./../../tools/", true, /\.js$/); + plugins_context.keys().forEach(function (key) { + if (key.indexOf('Base' + '/') < 0) { + var moduleKey = key.replace('./', '').replace('.js', ''); + var full_key = moduleKey; + if (moduleKey.indexOf('/') > -1) { + var parts = moduleKey.split("/"); + moduleKey = parts[parts.length - 1]; + } + + try { + var classObj = plugins_context(key); + if (!classObj.default) return; // skip helper files without a default export + var object = new classObj.default(ctx); + + var title = _this.Helper.ucfirst(object.name); + title = title.replace(/_/, ' '); + + _this.tools_modules[moduleKey] = { + key: moduleKey, + full_key: full_key, + name: object.name, + title: title, + object: object, + }; + + //init events once + if(typeof object.load != "undefined") { + object.load(); + } + } catch(e) { + console.error('[load_plugins] Failed to load ' + key + ':', e); + } + } + }); + } + + render_main_tools() { + this.load_plugins(); + + this.render_tools(); + } + + render_tools() { + var target_id = "tools_container"; + var _this = this; + var saved_tool = this.Helper.getCookie('active_tool'); + if(saved_tool == 'media' || saved_tool == 'shape') { + //bringing this back by default gives bad UX + saved_tool = null + } + if (saved_tool != null) { + this.active_tool = saved_tool; + } + + //left menu + for (var i in config.TOOLS) { + var item = config.TOOLS[i]; + if(item.title) + var title = item.title; + else + var title = this.Helper.ucfirst(item.name).replace(/_/, ' '); + + var itemDom = document.createElement('span'); + itemDom.id = item.name; + itemDom.title = title; + if (item.name == this.active_tool) { + itemDom.className = 'item trn active ' + item.name; + } + else { + itemDom.className = 'item trn ' + item.name; + } + if(item.visible === false){ + itemDom.style.display = 'none'; + } + + //event + itemDom.addEventListener('click', function (event) { + _this.activate_tool(this.id); + }); + + //register + document.getElementById(target_id).appendChild(itemDom); + } + + this.show_action_attributes(); + new app.Actions.Activate_tool_action(this.active_tool, true).do(); + this.Base_gui.check_canvas_offset(); + } + + async activate_tool(key) { + return app.State.do_action( + new app.Actions.Activate_tool_action(key) + ); + } + + action_data() { + for (var i in config.TOOLS) { + if (config.TOOLS[i].name == this.active_tool) + return config.TOOLS[i]; + } + + //something wrong - select first tool + this.active_tool = config.TOOLS[0].name; + return config.TOOLS[0]; + } + + /** + * used strings: + * "Fill", "Square", "Circle", "Radial", "Anti aliasing", "Circle", "Strict", "Burn" + */ + show_action_attributes() { + var _this = this; + var target_id = "action_attributes"; + + const itemContainer = document.getElementById(target_id); + + itemContainer.innerHTML = ""; + + const attributes = this.action_data().attributes; + + let itemDom; + let currentButtonGroup = null; + for (var k in attributes) { + var item = attributes[k]; + + var title = k[0].toUpperCase() + k.slice(1); + title = title.replace("_", " "); + + if (typeof item == 'object' && typeof item.value == 'boolean' && item.icon) { + if (currentButtonGroup == null) { + currentButtonGroup = document.createElement('div'); + currentButtonGroup.className = 'ui_button_group no_wrap'; + itemDom = document.createElement('div'); + itemDom.className = 'item ' + k; + itemContainer.appendChild(itemDom); + itemDom.appendChild(currentButtonGroup); + } else { + itemDom.classList.add(k); + } + } else { + itemDom = document.createElement('div'); + itemDom.className = 'item ' + k; + itemContainer.appendChild(itemDom); + currentButtonGroup = null; + } + + if (typeof item == 'boolean' || (typeof item == 'object' && typeof item.value == 'boolean')) { + //boolean - true, false + + let value = item; + let icon = null; + if (typeof item == 'object') { + value = item.value; + if (item.icon) { + icon = item.icon; + } + } + + const element = document.createElement('button'); + element.className = 'trn'; + element.type = 'button'; + element.id = k; + element.innerHTML = title; + element.setAttribute('aria-pressed', value); + if (icon) { + element.classList.add('ui_icon_button'); + element.classList.add('input_height'); + element.innerHTML = icon; + element.title = k; + element.innerHTML = ''+title+''; + } else { + element.classList.add('ui_toggle_button'); + } + //event + element.addEventListener('click', (event) => { + //toggle boolean + var new_value = element.getAttribute('aria-pressed') !== 'true'; + const actionData = this.action_data(); + const attributes = actionData.attributes; + const id = event.target.closest('button').id; + if (typeof attributes[id] === 'object') { + attributes[id].value = new_value; + } else { + attributes[id] = new_value; + } + element.setAttribute('aria-pressed', new_value); + if (actionData.on_update != undefined) { + //send event + var moduleKey = actionData.name; + var functionName = actionData.on_update; + this.tools_modules[moduleKey].object[functionName]({ key: id, value: new_value }); + } + }); + + if (currentButtonGroup) { + currentButtonGroup.appendChild(element); + } else { + itemDom.appendChild(element); + } + } + else if (typeof item == 'number' || (typeof item == 'object' && typeof item.value == 'number')) { + //numbers + let min = 1; + let max = k === 'power' ? 100 : 999; + let value = item; + let step = null; + if (typeof item == 'object') { + value = item.value; + if (item.min != null) { + min = item.min; + } + if (item.max != null) { + max = item.max; + } + if (item.step != null) { + step = item.step; + } + } + + var elementTitle = document.createElement('label'); + elementTitle.innerHTML = title + ':'; + elementTitle.id = 'attribute_label_' + k; + elementTitle.className = 'trn'; + + const elementInput = document.createElement('input'); + elementInput.type = 'number'; + elementInput.setAttribute('aria-labelledby', 'attribute_label_' + k); + const $numberInput = $(elementInput) + .uiNumberInput({ + id: k, + min, + max, + value, + step: step || 1, + exponentialStepButtons: !step + }) + .on('input', () => { + let value = $numberInput.uiNumberInput('get_value'); + const id = $numberInput.uiNumberInput('get_id'); + const actionData = this.action_data(); + const attributes = actionData.attributes; + if (typeof attributes[id] === 'object') { + attributes[id].value = value; + } else { + attributes[id] = value; + } + + if (actionData.on_update != undefined) { + //send event + var moduleKey = actionData.name; + var functionName = actionData.on_update; + this.tools_modules[moduleKey].object[functionName]({ key: id, value: value }); + } + }); + + itemDom.appendChild(elementTitle); + itemDom.appendChild($numberInput[0]); + } + else if (typeof item == 'object') { + //select + + var elementTitle = document.createElement('label'); + elementTitle.innerHTML = title + ':'; + elementTitle.for = k; + elementTitle.className = 'trn'; + + var selectList = document.createElement("select"); + selectList.id = k; + const values = typeof item.values === 'function' ? item.values() : item.values; + for (let j in values) { + var option = document.createElement("option"); + if (item.value == values[j]) { + option.selected = 'selected'; + } + option.className = 'trn'; + option.name = values[j]; + option.value = values[j]; + option.text = values[j]; + selectList.appendChild(option); + } + //event + selectList.addEventListener('change', (event) => { + const actionData = this.action_data(); + actionData.attributes[event.target.id].value = event.target.value; + + if (actionData.on_update != undefined) { + //send event + var moduleKey = actionData.name; + var functionName = actionData.on_update; + const result = this.tools_modules[moduleKey].object[functionName]({ key: event.target.id, value: event.target.value }); + if (result) { + // Allow the on_update function to modify the attribute value if necessary. + if (result.new_values) { + for (let key in result.new_values) { + actionData.attributes[key].value = result.new_values[key]; + } + } + } + } + + this.show_action_attributes(); + }); + + itemDom.appendChild(elementTitle); + itemDom.appendChild(selectList); + } + else if (typeof item == 'string' && item[0] == '#') { + //color + + var elementTitle = document.createElement('label'); + elementTitle.innerHTML = title + ':'; + elementTitle.for = k; + elementTitle.className = 'trn'; + + var colorInput = document.createElement('input'); + colorInput.type = 'color'; + const $colorInput = $(colorInput) + .uiColorInput({ + id: k, + value: item + }) + .on('change', () => { + let value = $colorInput.uiColorInput('get_value'); + const id = $colorInput.uiColorInput('get_id'); + const actionData = this.action_data(); + actionData.attributes[id] = value; + if (actionData.on_update != undefined) { + //send event + var moduleKey = actionData.name; + var functionName = actionData.on_update; + this.tools_modules[moduleKey].object[functionName]({ key: id, value: value }); + } + }); + + itemDom.appendChild(elementTitle); + itemDom.appendChild($colorInput[0]); + } + else { + alertify.error('Error: unsupported attribute type:' + typeof item + ', ' + k); + } + } + + if (config.LANG != 'en') { + //retranslate + this.Tools_translate.translate(config.LANG); + } + } + +} + +export default GUI_tools_class; diff --git a/paintplus/frontend/src/js/data/pantone_colors.js b/paintplus/frontend/src/js/data/pantone_colors.js new file mode 100644 index 0000000..d859cca --- /dev/null +++ b/paintplus/frontend/src/js/data/pantone_colors.js @@ -0,0 +1,238 @@ +/** + * Pantone color database — ~350 representative PMS colors with hex approximations. + * Source: open-source Pantone approximations (not official Pantone data). + * Format: [name, hex] + * + * Delta E matching uses LAB color space — see color_utils.js. + */ +const PANTONE_COLORS = [ + // Reds & Pinks + ['Pantone 485 C', '#da291c'], + ['Pantone 186 C', '#c8102e'], + ['Pantone 1795 C', '#ce2939'], + ['Pantone 1805 C', '#ab2328'], + ['Pantone 1815 C', '#833033'], + ['Pantone 200 C', '#ba0c2f'], + ['Pantone 201 C', '#9d2235'], + ['Pantone 202 C', '#862633'], + ['Pantone 206 C', '#ce0058'], + ['Pantone 207 C', '#a50034'], + ['Pantone 208 C', '#84254a'], + ['Pantone 213 C', '#e4488a'], + ['Pantone 214 C', '#d4357b'], + ['Pantone 215 C', '#bb2261'], + ['Pantone 219 C', '#e10069'], + ['Pantone 225 C', '#d5006c'], + ['Pantone 226 C', '#cb0070'], + ['Pantone 485 C', '#da291c'], + ['Pantone Pink C', '#e87ca0'], + ['Pantone Rubine Red C', '#ce0058'], + ['Pantone Rhodamine Red C', '#e10096'], + + // Oranges + ['Pantone 021 C', '#fe5000'], + ['Pantone 151 C', '#ff7900'], + ['Pantone 152 C', '#e87722'], + ['Pantone 153 C', '#cb6015'], + ['Pantone 158 C', '#e8642c'], + ['Pantone 165 C', '#fc4c02'], + ['Pantone 166 C', '#e55302'], + ['Pantone 167 C', '#be4b00'], + ['Pantone 1495 C', '#ff8200'], + ['Pantone 1505 C', '#ff671f'], + ['Pantone Orange 021 C', '#fe5000'], + ['Pantone Warm Red C', '#f9423a'], + + // Yellows + ['Pantone Yellow C', '#fedd00'], + ['Pantone 101 C', '#f9e84e'], + ['Pantone 102 C', '#fce300'], + ['Pantone 103 C', '#c5a900'], + ['Pantone 104 C', '#af9800'], + ['Pantone 108 C', '#f6d500'], + ['Pantone 109 C', '#ffd100'], + ['Pantone 110 C', '#d4af00'], + ['Pantone 115 C', '#fbdb65'], + ['Pantone 116 C', '#ffcd00'], + ['Pantone 117 C', '#c79200'], + ['Pantone 123 C', '#ffc72c'], + ['Pantone 124 C', '#e6a817'], + ['Pantone 130 C', '#f0aa00'], + ['Pantone 1205 C', '#f5e1a4'], + ['Pantone 1215 C', '#f5cf7e'], + ['Pantone 1225 C', '#fbb040'], + ['Pantone 1235 C', '#f7941d'], + ['Pantone 1245 C', '#d4890a'], + ['Pantone Gold C', '#af8c00'], + + // Greens + ['Pantone Green C', '#00ab84'], + ['Pantone 354 C', '#00b140'], + ['Pantone 355 C', '#009a44'], + ['Pantone 356 C', '#007a3d'], + ['Pantone 361 C', '#43b02a'], + ['Pantone 362 C', '#3d9a31'], + ['Pantone 363 C', '#347d2c'], + ['Pantone 368 C', '#78be20'], + ['Pantone 369 C', '#5da31c'], + ['Pantone 370 C', '#4a7729'], + ['Pantone 375 C', '#97d700'], + ['Pantone 376 C', '#72b200'], + ['Pantone 382 C', '#c4d600'], + ['Pantone 390 C', '#a8ad00'], + ['Pantone 334 C', '#00855d'], + ['Pantone 335 C', '#006a52'], + ['Pantone 336 C', '#00573f'], + ['Pantone 340 C', '#00843d'], + ['Pantone 341 C', '#00693c'], + ['Pantone 342 C', '#215732'], + ['Pantone 347 C', '#009a44'], + ['Pantone 348 C', '#007a3d'], + ['Pantone 349 C', '#215732'], + ['Pantone 3415 C', '#00665c'], + ['Pantone 3425 C', '#006a52'], + + // Teals & Cyans + ['Pantone Process Cyan C', '#0085ca'], + ['Pantone 306 C', '#00b5e2'], + ['Pantone 307 C', '#007dba'], + ['Pantone 308 C', '#005f86'], + ['Pantone 313 C', '#00b0ca'], + ['Pantone 314 C', '#0093ab'], + ['Pantone 315 C', '#007395'], + ['Pantone 320 C', '#009ca6'], + ['Pantone 321 C', '#008c95'], + ['Pantone 322 C', '#007680'], + ['Pantone 326 C', '#00b2a9'], + ['Pantone 327 C', '#007a74'], + ['Pantone 328 C', '#006E61'], + ['Pantone 3262 C', '#00b2a9'], + ['Pantone 3272 C', '#00a3ad'], + ['Pantone 3282 C', '#008c95'], + ['Pantone 3292 C', '#005f6a'], + + // Blues + ['Pantone Reflex Blue C', '#001489'], + ['Pantone Blue 072 C', '#10069f'], + ['Pantone 279 C', '#418fde'], + ['Pantone 280 C', '#003087'], + ['Pantone 281 C', '#002d72'], + ['Pantone 286 C', '#0033a0'], + ['Pantone 287 C', '#003087'], + ['Pantone 288 C', '#002d72'], + ['Pantone 293 C', '#0032a0'], + ['Pantone 294 C', '#002b6c'], + ['Pantone 295 C', '#002244'], + ['Pantone 300 C', '#0057a8'], + ['Pantone 301 C', '#005596'], + ['Pantone 302 C', '#003f72'], + ['Pantone 2728 C', '#2251b8'], + ['Pantone 2738 C', '#1b1464'], + ['Pantone 2748 C', '#0f1f8a'], + ['Pantone 2758 C', '#13234b'], + ['Pantone Bright Blue C', '#0087c8'], + ['Pantone 298 C', '#5bc8f5'], + ['Pantone 297 C', '#7bc4e2'], + + // Purples & Violets + ['Pantone Violet C', '#440099'], + ['Pantone 2587 C', '#8246af'], + ['Pantone 2597 C', '#6b1f7c'], + ['Pantone 2607 C', '#5e2175'], + ['Pantone 2617 C', '#522d6d'], + ['Pantone 2627 C', '#401752'], + ['Pantone 2665 C', '#9678d3'], + ['Pantone 2685 C', '#43009a'], + ['Pantone 2695 C', '#312068'], + ['Pantone 2705 C', '#8085c9'], + ['Pantone 2715 C', '#6e6bbf'], + ['Pantone 2725 C', '#4f52af'], + ['Pantone 2735 C', '#1f1a6e'], + ['Pantone 2745 C', '#1b1747'], + ['Pantone Ultra Violet C', '#5f4b8b'], + ['Pantone 259 C', '#6c2e8e'], + ['Pantone 266 C', '#6a2bb8'], + ['Pantone 267 C', '#521b8a'], + ['Pantone 268 C', '#43205e'], + ['Pantone 269 C', '#31184e'], + ['Pantone 253 C', '#b968c7'], + ['Pantone 254 C', '#aa4da0'], + ['Pantone 2562 C', '#c294d6'], + + // Magentas + ['Pantone Process Magenta C', '#d50087'], + ['Pantone Magenta 0521 C', '#d6006f'], + ['Pantone 233 C', '#c5007f'], + ['Pantone 234 C', '#a50064'], + ['Pantone 235 C', '#8c0056'], + ['Pantone 239 C', '#db5aa4'], + ['Pantone 240 C', '#bf4e99'], + + // Browns & Tans + ['Pantone 469 C', '#6b3d2e'], + ['Pantone 470 C', '#8c4a2f'], + ['Pantone 471 C', '#a05b38'], + ['Pantone 476 C', '#4e3629'], + ['Pantone 477 C', '#5c3d2e'], + ['Pantone 478 C', '#6d4535'], + ['Pantone 483 C', '#7a2e22'], + ['Pantone 484 C', '#9b3423'], + ['Pantone 4625 C', '#4a1c0e'], + ['Pantone 4635 C', '#7d3c1a'], + ['Pantone 4645 C', '#a45f3a'], + ['Pantone 4655 C', '#b87246'], + ['Pantone 463 C', '#7d5326'], + ['Pantone 464 C', '#8b5e27'], + ['Pantone 465 C', '#9e7232'], + ['Pantone 4505 C', '#8a7252'], + ['Pantone 4515 C', '#9e8866'], + ['Pantone 4525 C', '#b39e7a'], + ['Pantone Tan C', '#d2b48c'], + + // Grays + ['Pantone Cool Gray 1 C', '#d9d9d6'], + ['Pantone Cool Gray 2 C', '#d0d0ce'], + ['Pantone Cool Gray 3 C', '#c8c9c7'], + ['Pantone Cool Gray 4 C', '#bbbcbc'], + ['Pantone Cool Gray 5 C', '#b1b3b3'], + ['Pantone Cool Gray 6 C', '#a7a8aa'], + ['Pantone Cool Gray 7 C', '#97999b'], + ['Pantone Cool Gray 8 C', '#888b8d'], + ['Pantone Cool Gray 9 C', '#75787b'], + ['Pantone Cool Gray 10 C','#63666a'], + ['Pantone Cool Gray 11 C','#53565a'], + ['Pantone Warm Gray 1 C', '#d8d3cb'], + ['Pantone Warm Gray 2 C', '#cec6ba'], + ['Pantone Warm Gray 3 C', '#c4bbad'], + ['Pantone Warm Gray 4 C', '#bbb0a2'], + ['Pantone Warm Gray 5 C', '#b0a596'], + ['Pantone Warm Gray 6 C', '#a39891'], + ['Pantone Warm Gray 7 C', '#968c85'], + ['Pantone Warm Gray 8 C', '#8a7f76'], + ['Pantone Warm Gray 9 C', '#7d7368'], + ['Pantone Warm Gray 10 C','#72685d'], + ['Pantone Warm Gray 11 C','#655f56'], + ['Pantone 420 C', '#c7c7c4'], + ['Pantone 421 C', '#b5b6b3'], + ['Pantone 422 C', '#a4a4a1'], + ['Pantone 423 C', '#929291'], + ['Pantone 424 C', '#7f7f7d'], + ['Pantone 425 C', '#6c6c6c'], + ['Pantone 426 C', '#404040'], + + // Black & White + ['Pantone Black C', '#2b2926'], + ['Pantone Black 6 C','#101820'], + ['Pantone White', '#f2f0eb'], + + // Special / Brand colors + ['Pantone 021 C', '#fe5000'], // Harley-Davidson orange area + ['Pantone 484 C', '#9b3423'], + ['Pantone Bright Red C', '#f22613'], + ['Pantone 3005 C', '#0076c2'], + ['Pantone 3015 C', '#006298'], + ['Pantone 3025 C', '#005274'], + ['Pantone 3035 C', '#00445d'], +]; + +export default PANTONE_COLORS; diff --git a/paintplus/frontend/src/js/languages/ar.json b/paintplus/frontend/src/js/languages/ar.json new file mode 100644 index 0000000..a5732d2 --- /dev/null +++ b/paintplus/frontend/src/js/languages/ar.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "حدثت مشكلة أثناء إزالة محفوظات التراجع. هو - هي", + "About": "حول", + "Active": "نشيط", + "Aden": "عدن", + "Advanced": "متقدم", + "All": "الجميع", + "Alpha": "ألفا", + "Alpha:": "ألفا:", + "Anonymous": "مجهول", + "Anti aliasing": "مكافحة التعرج", + "Application markup may have changed,": "ربما تم تغيير ترميز التطبيق،", + "Arial": "اريال", + "Arrow": "سهم", + "ArrowDown": "السهم للاسفل", + "ArrowLeft": "السهم لليسار", + "ArrowRight": "السهم الأيمن", + "ArrowUp": "ارووب", + "Author:": "مؤلف:", + "Auto Adjust Colors": "ضبط تلقائي للألوان", + "Auto Kerning": "تقنين تلقائي لتقنين الأحرف", + "Average:": "متوسط:", + "Backspace": "مسافة للخلف", + "Base": "يتمركز", + "Basic": "أساسي", + "Black and White": "اسود و ابيض", + "Blue": "أزرق", + "Blue channel:": "القناة الزرقاء:", + "Blueprint": "مخطط", + "Blur Radius:": "نصف قطر التمويه:", + "Blur Tool": "أداة طمس", + "Blur power:": "قوة طمس:", + "Borders": "الحدود", + "Bottom": "قاع", + "Bottom to Top": "من الأسفل للأعلى", + "Bounds:": "الحدود:", + "Box": "علبة", + "Box Blur": "مربع طمس", + "Box blur": "مربع طمس", + "Brightness": "سطوع", + "Brightness:": "سطوع:", + "Bulge\/Pinch Tool": "أداة انتفاخ \/ قرصة", + "Burn": "حرق", + "Can not animate 1 layer.": "لا يمكن تحريك طبقة واحدة.", + "Can not find previous layer.": "لا يمكن العثور على الطبقة السابقة.", + "Can not use this tool on current layer: image already takes all area.": "لا يمكن استخدام هذه الأداة على الطبقة الحالية: الصورة تشغل المساحة بأكملها بالفعل.", + "Cancel": "يلغي", + "Canvas Size": "حجم قماش", + "Center": "مركز", + "Center x:": "المركز x:", + "Center y:": "مركز ص:", + "Center:": "مركز:", + "Change Composition": "تغيير التكوين", + "Change Layer Details": "تغيير تفاصيل الطبقة", + "Change Opacity": "تغيير التعتيم", + "Channel:": "قناة:", + "Circle": "دائرة", + "Clarendon": "كلاريندون", + "Clear": "واضح", + "Clear Selection": "التحديد الواضح", + "Clone Tool": "أداة استنساخ", + "Clone count:": "عدد النسخ:", + "Clone tool disabled for resized image. Please rasterize first.": "تم تعطيل أداة النسخ للصورة التي تم تغيير حجمها. يرجى التنقيط أولا.", + "Cloned edges": "حواف مستنسخة", + "Close": "يغلق", + "Color #": "اللون #", + "Color Corrections": "تصحيحات اللون", + "Color Palette": "لوحة الألوان", + "Color Zoom": "تكبير اللون", + "Color alpha value can not be zero.": "لا يمكن أن تكون قيمة ألفا للون صفراً.", + "Color to Alpha": "لون ألفا", + "Color zoom": "تكبير اللون", + "Color:": "اللون:", + "Colors": "الألوان", + "Colors:": "الألوان:", + "Common Filters": "مرشحات مشتركة", + "Composition": "تكوين", + "Composition:": "تكوين:", + "Content Fill": "تعبئة المحتوى", + "Contrast": "مقابلة", + "Contrast:": "مقابلة:", + "Convert layer to raster": "تحويل الطبقة إلى النقطية", + "Convert to Raster": "تحويل إلى نقطي", + "Copy Selection": "نسخ التحديد", + "Copy to Clipboard": "نسخ إلى الحافظة", + "Courier": "ساعي", + "Crop Tool": "أداة المحاصيل", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "القص على الطبقة التي تم تدويرها غير مدعوم. قم بتحويله إلى خطوط المسح للمتابعة.", + "Ctrl + C": "السيطرة + ج", + "Ctrl+A": "السيطرة + أ", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "السيطرة+P", + "Ctrl+V": "السيطرة + V.", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "السيطرة + Z", + "Current": "تيار", + "Current Color Preview": "معاينة اللون الحالي", + "Custom": "مخصص", + "Data URL": "URL البيانات", + "Data URL:": "URL البيانات:", + "Decrease": "تخفيض", + "Decrease Color Depth": "تقليل عمق اللون", + "Degree:": "درجة:", + "Del": "ديل", + "Delete": "حذف", + "Delete Selection": "حذف التحديد", + "Denoise": "يقلل الضوضاء", + "Desaturate Tool": "أداة إزالة التشبع", + "Description:": "وصف:", + "Deutsch": "الألمانية", + "Differences": "اختلافات", + "Differences Down": "الخلافات أسفل", + "Direction:": "اتجاه:", + "Dither": "ثبات", + "Dithering:": "التردد:", + "Dominant color:": "اللون السائد:", + "Dot Screen": "شاشة نقطية", + "Down": "أسفل", + "Duplicate": "ينسخ", + "Duplicate Layer": "طبقة مكررة", + "Duplicate layer": "طبقة مكررة", + "Dynamic": "متحرك", + "Edge": "حافة", + "Edit": "يحرر", + "Edit text...": "تحرير النص...", + "Effect browser": "متصفح التأثير", + "Effects": "تأثيرات", + "Effects browser": "متصفح التأثيرات", + "Email:": "بريد الالكتروني:", + "Emboss": "زخرف", + "Empty selection": "اختيار فارغ", + "Empty selection or type not image.": "اختيار فارغ أو اكتب ليس صورة.", + "Enable autoresize:": "تمكين إعادة الحجم التلقائي:", + "End": "نهاية", + "English": "الإنجليزية", + "English (UK)": "الإنجليزية (المملكة المتحدة)", + "Enrich": "يثرى", + "Enter": "يدخل", + "Erase Tool": "أداة المحو", + "Erase on rotate object is disabled. Please rasterize first.": "تم تعطيل المسح عند تدوير الكائن. يرجى التنقيط أولا.", + "Error": "خطأ", + "Error connecting to service.": "خطأ في الاتصال بالخدمة.", + "Error loading the list of fonts from Google.": "حدث خطأ أثناء تحميل قائمة الخطوط من Google.", + "Error registering service worker": "خطأ في تسجيل عامل الخدمة", + "Error: can not find filter:": "خطأ: لا يمكن العثور على عامل التصفية:", + "Error: can not find layer with id:": "خطأ: لا يمكن العثور على طبقة بالمعرف:", + "Error: missing details event target": "خطأ: تفاصيل الهدف حدث مفقود", + "Error: unknown layer type:": "خطأ: نوع طبقة غير معروف:", + "Error: unsupported attribute type:": "خطأ: نوع السمة غير مدعوم:", + "Esc": "خروج", + "Escape": "يهرب", + "Español": "الاسبانية", + "Expand edges": "قم بتوسيع الحواف", + "Exponent:": "الأس:", + "Export": "يصدر", + "External": "خارجي", + "Factor:": "عامل:", + "File": "ملف", + "File name:": "اسم الملف:", + "File size:": "حجم الملف:", + "Fill": "ملء", + "Fill Tool": "أداة التعبئة", + "Fit": "ملائم", + "Fit Window": "تناسب النافذة", + "Fit window": "نافذة مناسبة", + "Flatten Image": "تسطيح الصورة", + "Flip": "يواجه", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentine", + "Font": "الخط", + "Français": "الفرنسية", + "Full HD, 1080p": "دقة Full HD ، 1080 بكسل", + "Full Screen": "تكبير الشاشة", + "Full layers data": "بيانات الطبقات الكاملة", + "Gap:": "الفارق:", + "Gaussian Blur": "التمويه الضبابي", + "Gif delay:": "تأخير Gif:", + "Gingham": "القماش القطني", + "GitHub:": "جيثب:", + "Gradient Radius:": "نصف قطر التدرج:", + "Grains": "بقوليات", + "Graphics Interchange Format": "تنسيق تبادل الرسومات", + "Gray": "رمادي", + "Grayscale": "تدرج الرمادي", + "Greek": "اليونانية", + "Green": "لون أخضر", + "Green channel:": "القناة الخضراء:", + "Greyscale:": "الرمادي:", + "Grid": "شبكة", + "Grid on\/off": "الشبكة على \/ قبالة", + "Guides": "خطوط إرشاد", + "Guides enabled.": "تم تمكين الأدلة.", + "H Radius:": "نصف قطر H:", + "H. Align:": "ح. محاذاة:", + "Heatmap": "خريطة الحرارة", + "Height (%):": "ارتفاع (٪):", + "Height:": "ارتفاع:", + "Help": "مساعدة", + "Helvetica": "هيلفيتيكا", + "Hermite": "هيرمايت", + "Hex": "عرافة", + "Hide": "يخفي", + "Histogram": "الرسم البياني", + "Histogram:": "الرسم البياني:", + "Home": "الصفحة الرئيسية", + "Horizontal": "أفقي", + "Horizontal Alignment": "المحاذاة الأفقية", + "Horizontal blur:": "طمس أفقي:", + "Horizontal:": "أفقي:", + "Hue": "مسحة", + "Hue Rotate": "تدوير هوى", + "Hue:": "مسحة:", + "Image": "صورة", + "Image data with multi-layers. Can be opened using miniPaint -": "بيانات الصورة متعددة الطبقات. يمكن فتحه باستخدام miniPaint -", + "Impact": "تأثير", + "In proportion:": "في نسبة:", + "Increase": "زيادة", + "Information": "معلومة", + "Inkwell": "محبرة", + "Insert": "إدراج", + "Insert guides": "أدلة إدراج", + "Insert new layer": "أدخل طبقة جديدة", + "Instagram Filters": "مرشحات Instagram", + "Invalid Hex Code": "رمز سداسي عشري غير صالح", + "Italiano": "ايطالي", + "JPG\/JPEG Format": "تنسيق JPG \/ JPEG", + "Kerning:": "تقنين الأحرف:", + "Key-Points": "النقاط الرئيسية", + "KeyU": "KeyU", + "Keyboard Shortcuts": "اختصارات لوحة المفاتيح", + "Keyword:": "الكلمة الرئيسية:", + "Lanczos": "لانكوز", + "Landscape": "منظر جمالي", + "Language": "لغة", + "Last modified": "آخر تعديل", + "Layer": "طبقة", + "Layer details": "تفاصيل الطبقة", + "Layer is empty.": "الطبقة فارغة.", + "Layer is not compatible with resize": "الطبقة غير متوافقة مع تغيير الحجم", + "Layer is vector, convert it to raster to apply this tool.": "الطبقة متجهية ، قم بتحويلها إلى خطوط نقطية لتطبيق هذه الأداة.", + "Layers": "طبقات", + "Layers:": "طبقات:", + "Layout:": "تَخطِيط:", + "Left": "اليسار", + "Left to Right": "من اليسار إلى اليمين", + "Level:": "مستوى:", + "Levels:": "المستويات:", + "Lietuvių": "ليتوفيتش", + "Lo-fi": "Lo-fi", + "Luminance:": "الانارة:", + "Luminosity": "لمعان", + "Magic Eraser Tool": "أداة ماجيك ممحاة", + "Merge Down": "دمج أسفل", + "Merge Layers": "دمج الطبقات", + "Merged": "مندمجة", + "Metrics": "المقاييس", + "Middle": "وسط", + "Missing at least 1 size parameter.": "معلمة حجم واحدة مفقودة على الأقل.", + "Missing permissions to write to Clipboard.cc": "أذونات مفقودة للكتابة إلى Clipboard.cc", + "Mode:": "الوضع:", + "Module function not found.": "لم يتم العثور على وظيفة الوحدة النمطية.", + "Modules class not found:": "فئة الوحدات غير موجودة:", + "Monospace": "مونوسبيس", + "Mosaic": "فسيفساء", + "Mouse:": "الفأر:", + "Move": "يتحرك", + "Move Layer": "تحريك الطبقة", + "Move layer down": "انقل الطبقة إلى الأسفل", + "Move layer up": "حرك الطبقة لأعلى", + "Name:": "اسم:", + "Negative": "سلبي", + "New": "جديد", + "New Bezier Layer": "طبقة بيزيير جديدة", + "New Brush Layer": "طبقة فرشاة جديدة", + "New Ellipse Layer": "طبقة Ellipse جديدة", + "New File": "ملف جديد", + "New Gradient Layer": "طبقة متدرجة جديدة", + "New Layer": "طبقة جديدة", + "New Line Layer": "طبقة خط جديدة", + "New Pencil Layer": "طبقة قلم رصاص جديدة", + "New Polygon Layer": "طبقة مضلعة جديدة", + "New Rectangle Layer": "طبقة مستطيل جديدة", + "New Text Layer": "طبقة نص جديدة", + "New file": "ملف جديد", + "New from Selection": "جديد من التحديد", + "New layer": "طبقة جديدة", + "Next": "التالي", + "Night Vision": "الرؤية الليلية", + "None": "لا أحد", + "Nothing is selected.": "لم يتم اختيار شيء.", + "Offset X:": "تعويض X:", + "Offset Y:": "تعويض ص:", + "Oil": "زيت", + "Ok": "موافق", + "Online image editor.": "محرر الصور على الإنترنت.", + "Opacity": "العتامة", + "Opacity:": "العتامة:", + "Open": "فتح", + "Open Data URL": "فتح URL البيانات", + "Open Directory": "الدليل المفتوح", + "Open File": "افتح ملف", + "Open File Data URL": "فتح ملف بيانات URL", + "Open File URL": "فتح ملف URL", + "Open File Webcam": "افتح ملف كاميرا الويب", + "Open Image": "صورة مفتوحة", + "Open JSON File": "افتح ملف JSON", + "Open Test Template": "افتح نموذج الاختبار", + "Open URL": "رابط مفتوح", + "Open data URL": "فتح URL البيانات", + "Open from Webcam": "افتح من كاميرا الويب", + "Original Size": "الحجم الأصلي", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - تحويل الصورة إلى SVG", + "PageDown": "اسفل الصفحة", + "PageUp": "PageUp", + "Palette": "لوحة", + "Parameter #1:": "المعلمة # 1:", + "Parameter #2:": "المعلمة # 2:", + "Paste": "معجون", + "Pencil": "قلم", + "Percentage:": "النسبة المئوية:", + "Pixels:": "بكسل:", + "Placeholder comment for color channels": "تعليق العنصر النائب لقنوات الألوان", + "Placeholder comment for color picker": "تعليق العنصر النائب لمنتقي الألوان", + "Placeholder comment for color swatches": "تعليق العنصر النائب لحوامل اللون", + "Portable Network Graphics": "رسومات الشبكة المحمولة", + "Portrait": "لَوحَة", + "Português": "البرتغالية", + "Position:": "موقع:", + "Power:": "قوة:", + "Preview": "معاينة", + "Previous": "سابق", + "Previous layer must be image, convert it to raster to apply this tool.": "يجب أن تكون الطبقة السابقة صورة ، قم بتحويلها إلى نقطية لتطبيق هذه الأداة.", + "Print": "مطبعة", + "Quality:": "جودة:", + "Quick Load": "تحميل سريع", + "Quick Save": "حفظ سريع", + "REMOVE.BG - Remove Image Background": "؛ REMOVE.BG - إزالة خلفية الصورة", + "Radial": "شعاعي", + "Radial gradient": "شعاعي التدرج", + "Radius:": "نصف القطر:", + "Range:": "نطاق:", + "Red": "أحمر", + "Red channel:": "القناة الحمراء:", + "Redo": "إعادة", + "Remove all": "حذف الكل", + "Rename": "إعادة تسمية", + "Rename Layer": "إعادة تسمية الطبقة", + "Rendered with errors.": "قدمت مع وجود أخطاء.", + "Rendering...": "استدعاء...", + "Replace Color": "استبدل اللون", + "Replace color": "استبدل اللون", + "Replacement:": "إستبدال:", + "Report Issues": "الإبلاغ عن المشكلات", + "Reset": "إعادة ضبط", + "Resize": "تغيير الحجم", + "Resize Boundary": "تغيير حجم الحدود", + "Resize Layer": "طبقة تغيير الحجم", + "Resize Layers": "تغيير حجم الطبقات", + "Resize Text Layer": "تغيير حجم طبقة النص", + "Resized as background": "تم تغيير الحجم كخلفية", + "Resized:": "تم تغيير الحجم:", + "Resolution:": "القرار:", + "Restore Alpha": "استعادة ألفا", + "Right": "حق", + "Right angle:": "زاوية مستقيمة:", + "Right to Left": "من اليمين الى اليسار", + "Rotate": "استدارة", + "Rotate Layer": "تدوير طبقة", + "Rotate is not supported on this type of object. Convert to raster?": "التدوير غير مدعوم في هذا النوع من الكائنات. تحويل إلى نقطية؟", + "Rotate left": "استدر يسارا", + "Rotate:": "استدارة:", + "Ruler": "مسطرة", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - ضغط ومقارنة الصور", + "Saturate": "تشبع", + "Saturation": "التشبع", + "Saturation:": "التشبع:", + "Save As": "حفظ باسم", + "Save As Data URL": "حفظ باسم URL البيانات", + "Save as": "حفظ باسم", + "Save as type:": "حفظ كنوع:", + "Save layers:": "حفظ الطبقات:", + "Scaling up is not supported in Hermite, using Lanczos.": "التوسع غير مدعوم في Hermite ، باستخدام Lanczos.", + "Scroll down": "حرك الفأرة لأسفل", + "Scroll up": "انتقل إلى أعلى", + "Search": "بحث", + "Search Images": "البحث عن الصور", + "Search for Font": "البحث عن الخط", + "Search:": "يبحث:", + "Select All": "اختر الكل", + "Select Text Layer": "حدد طبقة النص", + "Select object tool": "حدد أداة الكائن", + "Selected": "المحدد", + "Selection Tool": "آلة الاختيار", + "Sensitivity:": "حساسية:", + "Separated": "منفصل", + "Separated (original types)": "منفصل (الأنواع الأصلية)", + "Sepia": "بني داكن", + "Set Image Size": "ضبط حجم الصورة", + "Settings": "إعدادات", + "Shadow": "ظل", + "Shapes": "الأشكال", + "Shapes (H)": "الأشكال (ح)", + "Sharpen": "شحذ", + "Sharpen Tool": "أداة شحذ", + "Sharpen:": "شحذ:", + "Shift + S": "التحول + س", + "Shortcut Key:": "مفتاح الاختصار:", + "Show": "يعرض", + "Show \/ Hide": "اظهر المخفي", + "Show file size:": "إظهار حجم الملف:", + "Simple": "بسيط", + "Size is too big, max": "الحجم كبير جدًا ، الحد الأقصى", + "Size:": "مقاس:", + "Skip - layer must be image.": "تخطي - يجب أن تكون الطبقة عبارة عن صورة.", + "Solarize": "شمسي", + "Sorry, cold not load getUserMedia() data:": "عذرا ، لا تقم بتحميل بيانات getUserMedia ():", + "Sorry, image could not be loaded.": "عذرا ، الصورة لا يمكن تحميلها.", + "Sorry, image could not be loaded. Try copy image and paste it.": "عذرا ، الصورة لا يمكن تحميلها. حاول نسخ الصورة ولصقها.", + "Sorry, image is too big, max 5 MB.": "عذرًا ، الصورة كبيرة جدًا ، بحد أقصى 5 ميجا بايت.", + "Source coordinates saved.": "تم حفظ إحداثيات المصدر.", + "Source is empty, right click on image or use long press to save source position.": "المصدر فارغ ، انقر بزر الماوس الأيمن على الصورة أو استخدم الضغط لفترة طويلة لحفظ موضع المصدر.", + "Sprites": "العفاريت", + "Square": "مربع", + "Stream:": "مجرى:", + "Strength:": "قوة:", + "Strict": "صارم", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - ضغط PNG و JPEG", + "Tab": "فاتورة غير مدفوعة", + "Tag Image File Format": "تنسيق ملف صورة العلامة", + "Tahoma": "تاهوما", + "Target:": "استهداف:", + "The quick brown fox jumps over the lazy dog.": "الثعلب البني السريع يقفز فوق الكلب الكسول.", + "There": "هناك", + "There are no layers behind.": "لا توجد طبقات خلف.", + "There is only 1 layer.": "هناك طبقة واحدة فقط.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "يجب أن تحتوي هذه الطبقة على صورة. يرجى تحويله إلى نقطية لتطبيق هذه الأداة.", + "Tilt Shift": "تحول الإمالة", + "Times New Roman": "تايمز نيو رومان", + "Toaster": "محمصة", + "Toggle": "تبديل", + "Toggle Color Channels": "تبديل قنوات الألوان", + "Toggle Color Picker": "تبديل منتقي الألوان", + "Toggle Menu": "تبديل القائمة", + "Toggle Swatches": "تبديل العينات", + "Tools": "أدوات", + "Top": "قمة", + "Top to Bottom": "من اعلى لاسفل", + "Total pixels:": "إجمالي وحدات البكسل:", + "Translate": "ترجمة", + "Translate Layer": "طبقة الترجمة", + "Translate error, can not find dictionary:": "خطأ في الترجمة ، لا يمكن العثور على القاموس:", + "Transparent:": "شفاف:", + "Trim": "تقليم", + "Trim Layers": "طبقات القطع", + "Trim borders:": "تقليم الحدود:", + "Trim layer:": "طبقة القطع:", + "Trim white color?": "تقليم اللون الأبيض؟", + "Type:": "اكتب:", + "Türkçe": "Türkçe", + "Undo": "الغاء التحميل", + "Unique colors:": "ألوان فريدة:", + "Up": "فوق", + "Update": "تحديث", + "Update Brush Layer": "تحديث طبقة الفرشاة", + "Update Pencil Layer": "تحديث طبقة القلم الرصاص", + "Update guides": "أدلة التحديث", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "استخدم اختصار لوحة المفاتيح Ctrl + V للصق من الحافظة.", + "V Radius:": "نصف القطر الخامس:", + "V. Align:": "V. محاذاة:", + "Valencia": "فالنسيا", + "Verdana": "فيردانا", + "Version:": "الإصدار:", + "Vertical": "رأسي", + "Vertical Alignment": "انحياز عمودي", + "Vertical blur:": "التمويه العمودي:", + "Vertical:": "رأسي:", + "Vibrance": "حيوية", + "View": "رأي", + "Vignette": "المقالة القصيرة", + "ViliusL": "ViliusL", + "Vintage": "كلاسيكي", + "Webcam": "كاميرا ويب", + "Webcam #": "كاميرا ويب #", + "Website:": "موقع الكتروني:", + "Weppy File Format": "تنسيق ملف Weppy", + "Width (%):": "عرض (٪):", + "Width:": "عرض:", + "Windows Bitmap": "Windows Bitmap", + "Word": "كلمة", + "Word + Letter": "كلمة + حرف", + "Wrap At:": "التفاف في:", + "Wrap:": "لف:", + "Wrong dimensions": "أبعاد خاطئة", + "Wrong file type, must be image or json.": "نوع الملف غير صحيح ، يجب أن يكون صورة أو json.", + "X end:": "نهاية X:", + "X position:": "المركز العاشر:", + "X start:": "بداية X:", + "X-Pro II": "اكس برو الثاني", + "Y end:": "نهاية ص:", + "Y position:": "موقف ص:", + "Y start:": "بداية Y:", + "You can also drag and drop items into browser.": "يمكنك أيضًا سحب العناصر وإفلاتها في المتصفح.", + "Your browser does not support canvas or JavaScript is not enabled.": "لا يدعم المستعرض الخاص بك اللوحة القماشية أو لم يتم تمكين JavaScript.", + "Your browser does not support this format.": "متصفحك لا يدعم هذا التنسيق.", + "Your search did not match any images.": "بحثك لم يطابق أي صور.", + "Zoom": "تكبير", + "Zoom Blur": "زووم بلور", + "Zoom In": "تكبير", + "Zoom Out": "تصغير", + "Zoom blur": "زووم طمس", + "Zoom in": "تكبير", + "Zoom out": "تصغير", + "Zoom:": "تكبير:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/credits.js b/paintplus/frontend/src/js/languages/credits.js new file mode 100644 index 0000000..1549b3a --- /dev/null +++ b/paintplus/frontend/src/js/languages/credits.js @@ -0,0 +1,3 @@ +/* + * Fr - Toad06 + */ \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/de.json b/paintplus/frontend/src/js/languages/de.json new file mode 100644 index 0000000..ef5b4cb --- /dev/null +++ b/paintplus/frontend/src/js/languages/de.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Beim Entfernen des Rückgängig-Verlaufs ist ein Problem aufgetreten. Es", + "About": "Über", + "Active": "Aktiv", + "Aden": "Aden", + "Advanced": "Fortgeschritten", + "All": "Alle", + "Alpha": "Alpha", + "Alpha:": "Alpha:", + "Anonymous": "Anonym", + "Anti aliasing": "Kantenglättung", + "Application markup may have changed,": "Das Anwendungs-Markup hat sich möglicherweise geändert.", + "Arial": "Arial", + "Arrow": "Pfeil", + "ArrowDown": "Pfeil nach unten", + "ArrowLeft": "Pfeil links", + "ArrowRight": "Pfeil rechts", + "ArrowUp": "Pfeil nach oben", + "Author:": "Autor:", + "Auto Adjust Colors": "Automatische Farbeinstellung", + "Auto Kerning": "Auto Kerning", + "Average:": "Durchschnitt:", + "Backspace": "Rücktaste", + "Base": "Basis", + "Basic": "Basic", + "Black and White": "Schwarz und weiß", + "Blue": "Blau", + "Blue channel:": "Blauer Kanal:", + "Blueprint": "Entwurf", + "Blur Radius:": "Weichzeichner-Radius:", + "Blur Tool": "Unschärfewerkzeug", + "Blur power:": "Weichzeichner-Stärke:", + "Borders": "Grenzen", + "Bottom": "Unterseite", + "Bottom to Top": "Unten nach oben", + "Bounds:": "Grenzen:", + "Box": "Box", + "Box Blur": "Box Unschärfe", + "Box blur": "Box Unschärfe", + "Brightness": "Helligkeit", + "Brightness:": "Helligkeit:", + "Bulge\/Pinch Tool": "Ausbuchtungs- \/ Quetschwerkzeug", + "Burn": "Brennen", + "Can not animate 1 layer.": "1 Ebene kann nicht animiert werden.", + "Can not find previous layer.": "Die vorherige Ebene kann nicht gefunden werden.", + "Can not use this tool on current layer: image already takes all area.": "Dieses Werkzeug kann auf der aktuellen Ebene nicht verwendet werden: Das Bild nimmt bereits den gesamten Bereich ein.", + "Cancel": "Abbrechen", + "Canvas Size": "Leinwandgröße", + "Center": "Zentrum", + "Center x:": "Mitte x:", + "Center y:": "Mitte y:", + "Center:": "Zentrum:", + "Change Composition": "Zusammensetzung ändern", + "Change Layer Details": "Layerdetails ändern", + "Change Opacity": "Deckkraft ändern", + "Channel:": "Kanal:", + "Circle": "Kreis", + "Clarendon": "Clarendon", + "Clear": "Löschen", + "Clear Selection": "Auswahl löschen", + "Clone Tool": "Klon-Tool", + "Clone count:": "Klonanzahl:", + "Clone tool disabled for resized image. Please rasterize first.": "Das Klon-Tool ist für das in der Größe geänderte Bild deaktiviert. Bitte zuerst rastern.", + "Cloned edges": "Klonierte Kanten", + "Close": "Schließen", + "Color #": "Farbe #", + "Color Corrections": "Farbkorrekturen", + "Color Palette": "Farbpalette", + "Color Zoom": "Farbzoom", + "Color alpha value can not be zero.": "Farb-Alpha-Wert kann nicht Null sein.", + "Color to Alpha": "Farbe zu Alpha", + "Color zoom": "Farbzoom", + "Color:": "Farbe:", + "Colors": "Farben", + "Colors:": "Farben:", + "Common Filters": "Allgemeine Filter", + "Composition": "Zusammensetzung", + "Composition:": "Zusammensetzung:", + "Content Fill": "Inhalt ausfüllen", + "Contrast": "Kontrast", + "Contrast:": "Kontrast:", + "Convert layer to raster": "Konvertieren Sie die Ebene in ein Raster", + "Convert to Raster": "In Raster konvertieren", + "Copy Selection": "Auswahl kopieren", + "Copy to Clipboard": "In die Zwischenablage kopieren", + "Courier": "Kurier", + "Crop Tool": "Freistellungswerkzeug", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Das Zuschneiden auf einer gedrehten Ebene wird nicht unterstützt. Konvertieren Sie es in Raster, um fortzufahren.", + "Ctrl + C": "Strg + C", + "Ctrl+A": "Strg + A.", + "Ctrl+C": "Strg + C.", + "Ctrl+P": "Strg+P", + "Ctrl+V": "Strg + V", + "Ctrl+Y": "Strg + Y.", + "Ctrl+Z": "Strg + Z.", + "Current": "Aktuell", + "Current Color Preview": "Aktuelle Farbvorschau", + "Custom": "Individuell", + "Data URL": "Daten-URL", + "Data URL:": "Daten-URL:", + "Decrease": "Verringern", + "Decrease Color Depth": "Farbtiefe verringern", + "Degree:": "Grad:", + "Del": "Del", + "Delete": "Löschen", + "Delete Selection": "Auswahl löschen", + "Denoise": "Denoise", + "Desaturate Tool": "Entsättigtes Werkzeug", + "Description:": "Beschreibung:", + "Deutsch": "Deutsch", + "Differences": "Unterschiede", + "Differences Down": "Unterschiede nach unten", + "Direction:": "Richtung:", + "Dither": "Dither", + "Dithering:": "Dithering:", + "Dominant color:": "Dominierende Farbe:", + "Dot Screen": "Punkt-Bildschirm", + "Down": "Runter", + "Duplicate": "Duplikat", + "Duplicate Layer": "Ebene duplizieren", + "Duplicate layer": "Ebene duplizieren", + "Dynamic": "Dynamisch", + "Edge": "Kante", + "Edit": "Bearbeiten", + "Edit text...": "Text bearbeiten...", + "Effect browser": "Effektbrowser", + "Effects": "Filter", + "Effects browser": "Effektbrowser", + "Email:": "Email:", + "Emboss": "Prägen", + "Empty selection": "Leere Auswahl", + "Empty selection or type not image.": "Leere Auswahl oder kein Bildtyp.", + "Enable autoresize:": "Automatische Größenänderung aktivieren:", + "End": "Ende", + "English": "Englisch", + "English (UK)": "Englisch UK)", + "Enrich": "Bereichern", + "Enter": "Eingeben", + "Erase Tool": "Löschwerkzeug", + "Erase on rotate object is disabled. Please rasterize first.": "„Löschen beim Drehen des Objekts“ ist deaktiviert. Bitte zuerst rastern.", + "Error": "Fehler", + "Error connecting to service.": "Fehler beim Verbinden mit dem Dienst.", + "Error loading the list of fonts from Google.": "Fehler beim Laden der Schriftartenliste von Google.", + "Error registering service worker": "Fehler beim Registrieren des Servicemitarbeiters", + "Error: can not find filter:": "Fehler: Filter kann nicht gefunden werden:", + "Error: can not find layer with id:": "Fehler: Layer mit ID kann nicht gefunden werden:", + "Error: missing details event target": "Fehler: Details zum Ereignis fehlen", + "Error: unknown layer type:": "Fehler: unbekannter Layertyp:", + "Error: unsupported attribute type:": "Fehler: nicht unterstützter Attributtyp:", + "Esc": "Esc", + "Escape": "Flucht", + "Español": "Spanisch", + "Expand edges": "Kanten erweitern", + "Exponent:": "Exponent:", + "Export": "Export", + "External": "Extern", + "Factor:": "Faktor:", + "File": "Datei", + "File name:": "Dateiname:", + "File size:": "Dateigröße:", + "Fill": "Füllen", + "Fill Tool": "Füllwerkzeug", + "Fit": "Passen", + "Fit Window": "Fenster einpassen", + "Fit window": "Fenster einbauen", + "Flatten Image": "Zu einer Ebene vereinigen", + "Flip": "Spiegeln", + "FloydSteinberg-serpentine": "FloydSteinberg-Serpentin", + "Font": "Schriftart", + "Français": "Français", + "Full HD, 1080p": "Volles HD, 1080p", + "Full Screen": "Ganzer Bildschirm", + "Full layers data": "Vollständige Layer-Daten", + "Gap:": "Spalt:", + "Gaussian Blur": "Gaußscher Weichzeichner", + "Gif delay:": "Gif Verzögerung:", + "Gingham": "Gingham", + "GitHub:": "GitHub:", + "Gradient Radius:": "Gradient Radius:", + "Grains": "Körner", + "Graphics Interchange Format": "Grafikaustauschformat", + "Gray": "Grau", + "Grayscale": "Graustufen", + "Greek": "griechisch", + "Green": "Grün", + "Green channel:": "Grüner Kanal:", + "Greyscale:": "Graustufen:", + "Grid": "Raster", + "Grid on\/off": "Raster ein \/ aus", + "Guides": "Führer", + "Guides enabled.": "Anleitungen aktiviert.", + "H Radius:": "H Radius:", + "H. Align:": "H. Ausrichten:", + "Heatmap": "Heatmap", + "Height (%):": "Höhe (%):", + "Height:": "Höhe:", + "Help": "Hilfe", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Verhexen", + "Hide": "Verstecken", + "Histogram": "Histogramm", + "Histogram:": "Histogramm:", + "Home": "Zuhause", + "Horizontal": "Horizontal", + "Horizontal Alignment": "Horizontale Ausrichtung", + "Horizontal blur:": "Horizontale Unschärfe:", + "Horizontal:": "Horizontal:", + "Hue": "Farbton", + "Hue Rotate": "Farbton drehen", + "Hue:": "Farbton:", + "Image": "Bild", + "Image data with multi-layers. Can be opened using miniPaint -": "Bilddaten mit mehreren Ebenen. Kann mit miniPaint geöffnet werden -", + "Impact": "Auswirkung", + "In proportion:": "Im Verhältnis:", + "Increase": "Erhöhen, ansteigen", + "Information": "Information", + "Inkwell": "Tintenfass", + "Insert": "Einfügen", + "Insert guides": "Führungen einfügen", + "Insert new layer": "Neue Ebene einfügen", + "Instagram Filters": "Instagram Filter", + "Invalid Hex Code": "Ungültiger Hex-Code", + "Italiano": "Italienisch", + "JPG\/JPEG Format": "JPG \/ JPEG-Format", + "Kerning:": "Kerning:", + "Key-Points": "Schlüsselpunkte", + "KeyU": "KeyU", + "Keyboard Shortcuts": "Tastatürkürzel", + "Keyword:": "Stichwort:", + "Lanczos": "Lanczos", + "Landscape": "Landschaft", + "Language": "Sprache", + "Last modified": "Zuletzt bearbeitet", + "Layer": "Schicht", + "Layer details": "Ebenendetails", + "Layer is empty.": "Die Ebene ist leer.", + "Layer is not compatible with resize": "Die Ebene ist nicht mit der Größenänderung kompatibel", + "Layer is vector, convert it to raster to apply this tool.": "Die Ebene ist ein Vektor. Konvertieren Sie sie in ein Raster, um dieses Werkzeug anzuwenden.", + "Layers": "Ebenen", + "Layers:": "Ebenen:", + "Layout:": "Layout:", + "Left": "Links", + "Left to Right": "Links nach rechts", + "Level:": "Niveau:", + "Levels:": "Stufen:", + "Lietuvių": "Litauisch", + "Lo-fi": "Lo-Fi", + "Luminance:": "Leuchtdichte:", + "Luminosity": "Helligkeit", + "Magic Eraser Tool": "Magic Eraser Tool", + "Merge Down": "Nach unten vereinigen", + "Merge Layers": "Ebenen zusammenführen", + "Merged": "Zusammengeführt", + "Metrics": "Metriken", + "Middle": "Mitte", + "Missing at least 1 size parameter.": "Mindestens 1 Größenparameter fehlt.", + "Missing permissions to write to Clipboard.cc": "Fehlende Berechtigungen zum Schreiben in Clipboard.cc", + "Mode:": "Modus:", + "Module function not found.": "Modulfunktion nicht gefunden.", + "Modules class not found:": "Modulklasse nicht gefunden:", + "Monospace": "Monospace", + "Mosaic": "Mosaik", + "Mouse:": "Maus:", + "Move": "Bewegung", + "Move Layer": "Ebene verschieben", + "Move layer down": "Ebene nach unten verschieben", + "Move layer up": "Ebene nach oben verschieben", + "Name:": "Name:", + "Negative": "Negativ", + "New": "Neu", + "New Bezier Layer": "Neue Bezier-Ebene", + "New Brush Layer": "Neue Pinselschicht", + "New Ellipse Layer": "Neue Ellipsenebene", + "New File": "Neue Datei", + "New Gradient Layer": "Neue Verlaufsebene", + "New Layer": "Neue Schicht", + "New Line Layer": "Neue Linienebene", + "New Pencil Layer": "Neue Bleistiftebene", + "New Polygon Layer": "Neue Polygonebene", + "New Rectangle Layer": "Neue Rechteckschicht", + "New Text Layer": "Neue Textebene", + "New file": "Neue Datei", + "New from Selection": "Neu von Auswahl", + "New layer": "Neue Ebene", + "Next": "Nächste", + "Night Vision": "Nachtsicht", + "None": "Keiner", + "Nothing is selected.": "Nichts ausgewählt.", + "Offset X:": "Offset X:", + "Offset Y:": "Offset Y:", + "Oil": "Öl", + "Ok": "OK", + "Online image editor.": "Online Bildbearbeitung.", + "Opacity": "Opazität", + "Opacity:": "Opazität:", + "Open": "Öffnen", + "Open Data URL": "Öffnen Sie die Daten-URL", + "Open Directory": "Verzeichnis öffnen", + "Open File": "Datei öffnen", + "Open File Data URL": "Öffnen Sie die Dateidaten-URL", + "Open File URL": "Öffnen Sie die Datei-URL", + "Open File Webcam": "Öffnen Sie die Datei-Webcam", + "Open Image": "Bild öffnen", + "Open JSON File": "Öffnen Sie die JSON-Datei", + "Open Test Template": "Öffnen Sie die Testvorlage", + "Open URL": "Öffne URL", + "Open data URL": "Öffnen Sie die Daten-URL", + "Open from Webcam": "Von der Webcam öffnen", + "Original Size": "Originalgröße", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Bild in SVG konvertieren", + "PageDown": "Bild nach unten", + "PageUp": "PageUp", + "Palette": "Palette", + "Parameter #1:": "Parameter # 1:", + "Parameter #2:": "Parameter # 2:", + "Paste": "Einfügen", + "Pencil": "Bleistift", + "Percentage:": "Prozentsatz:", + "Pixels:": "Pixel:", + "Placeholder comment for color channels": "Platzhalterkommentar für Farbkanäle", + "Placeholder comment for color picker": "Platzhalterkommentar für Farbwähler", + "Placeholder comment for color swatches": "Platzhalterkommentar für Farbfelder", + "Portable Network Graphics": "Tragbare Netzwerkgrafiken", + "Portrait": "Porträt", + "Português": "Português", + "Position:": "Position:", + "Power:": "Leistung:", + "Preview": "Vorschau", + "Previous": "Bisherige", + "Previous layer must be image, convert it to raster to apply this tool.": "Die vorherige Ebene muss ein Bild sein, wandeln Sie sie in ein Raster um, um dieses Werkzeug anzuwenden.", + "Print": "Drucken", + "Quality:": "Qualität:", + "Quick Load": "Schnell laden", + "Quick Save": "Schnellspeichern", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Bildhintergrund entfernen", + "Radial": "Radial", + "Radial gradient": "Radialer Verlauf", + "Radius:": "Radius:", + "Range:": "Angebot:", + "Red": "Rot", + "Red channel:": "Roter Kanal:", + "Redo": "Wiederholen", + "Remove all": "Alles entfernen", + "Rename": "Umbenennen", + "Rename Layer": "Ebene umbenennen", + "Rendered with errors.": "Mit Fehlern gerendert.", + "Rendering...": "Rendern ...", + "Replace Color": "Farbe ersetzen", + "Replace color": "Farbe ersetzen", + "Replacement:": "Ersatz:", + "Report Issues": "Probleme melden", + "Reset": "Zurücksetzen", + "Resize": "Größe ändern", + "Resize Boundary": "Größe der Grenze ändern", + "Resize Layer": "Ändern Sie die Größe der Ebene", + "Resize Layers": "Ändern Sie die Größe von Ebenen", + "Resize Text Layer": "Ändern Sie die Größe der Textebene", + "Resized as background": "Größe als Hintergrund", + "Resized:": "Größe geändert:", + "Resolution:": "Auflösung:", + "Restore Alpha": "Alpha wiederherstellen", + "Right": "Recht", + "Right angle:": "Rechter Winkel:", + "Right to Left": "Rechts nach links", + "Rotate": "Drehen", + "Rotate Layer": "Ebene drehen", + "Rotate is not supported on this type of object. Convert to raster?": "Drehen wird bei diesem Objekttyp nicht unterstützt. In Raster konvertieren?", + "Rotate left": "Nach links drehen", + "Rotate:": "Drehen:", + "Ruler": "Herrscher", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Bilder komprimieren und vergleichen", + "Saturate": "Sättigen", + "Saturation": "Sättigung", + "Saturation:": "Sättigung:", + "Save As": "Speichern als", + "Save As Data URL": "Als Daten-URL speichern", + "Save as": "Speichern als", + "Save as type:": "Speichern unter:", + "Save layers:": "Ebenen speichern:", + "Scaling up is not supported in Hermite, using Lanczos.": "Das Skalieren wird in Hermite mit Lanczos nicht unterstützt.", + "Scroll down": "Runterscrollen", + "Scroll up": "Hochscrollen", + "Search": "Suche", + "Search Images": "Bilder suchen", + "Search for Font": "Suchen Sie nach Schriftart", + "Search:": "Suchen:", + "Select All": "Alles auswählen", + "Select Text Layer": "Wählen Sie Textebene", + "Select object tool": "Wählen Sie das Objektwerkzeug aus", + "Selected": "Ausgewählt", + "Selection Tool": "Auswahlwerkzeug", + "Sensitivity:": "Empfindlichkeit:", + "Separated": "Getrennt", + "Separated (original types)": "Getrennt (Originaltypen)", + "Sepia": "Sepia", + "Set Image Size": "Stellen Sie die Bildgröße ein", + "Settings": "Einstellungen", + "Shadow": "Schatten", + "Shapes": "Formen", + "Shapes (H)": "Formen (H)", + "Sharpen": "Schärfen", + "Sharpen Tool": "Werkzeug schärfen", + "Sharpen:": "Schärfen:", + "Shift + S": "Umschalt + S", + "Shortcut Key:": "Tastenkürzel:", + "Show": "Zeigen", + "Show \/ Hide": "Anzeigen Ausblenden", + "Show file size:": "Dateigröße anzeigen:", + "Simple": "Einfach", + "Size is too big, max": "Größe ist zu groß, max", + "Size:": "Größe:", + "Skip - layer must be image.": "Überspringen - Ebene muss ein Bild sein.", + "Solarize": "Solarisieren", + "Sorry, cold not load getUserMedia() data:": "Sorry, kalt getUserMedia () Daten nicht laden:", + "Sorry, image could not be loaded.": "Das Bild konnte leider nicht geladen werden.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Entschuldigung, Bild konnte nicht geladen werden. Versuchen Sie, das Bild zu kopieren und einzufügen.", + "Sorry, image is too big, max 5 MB.": "Entschuldigung, das Bild ist zu groß, maximal 5 MB.", + "Source coordinates saved.": "Quellkoordinaten gespeichert.", + "Source is empty, right click on image or use long press to save source position.": "Quelle ist leer, klicken Sie mit der rechten Maustaste auf das Bild oder drücken Sie lange, um die Position der Quelle zu speichern.", + "Sprites": "Sprites", + "Square": "Rechteck", + "Stream:": "Strom:", + "Strength:": "Stärke:", + "Strict": "Streng", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Komprimiert PNG und JPEG", + "Tab": "Tab", + "Tag Image File Format": "Markieren Sie das Bilddateiformat", + "Tahoma": "Tahoma", + "Target:": "Ziel:", + "The quick brown fox jumps over the lazy dog.": "Der schnelle Braunfuchs springt über den faulen Hund.", + "There": "Dort", + "There are no layers behind.": "Es gibt keine Ebenen dahinter.", + "There is only 1 layer.": "Es gibt nur 1 Ebene.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Diese Ebene muss ein Bild enthalten. Bitte konvertieren Sie es in ein Raster, um dieses Tool anzuwenden.", + "Tilt Shift": "Neigung Verschiebung", + "Times New Roman": "Times New Roman", + "Toaster": "Toaster", + "Toggle": "Umschalten", + "Toggle Color Channels": "Farbkanäle umschalten", + "Toggle Color Picker": "Farbwähler umschalten", + "Toggle Menu": "Menü umschalten", + "Toggle Swatches": "Farbfelder umschalten", + "Tools": "Werkzeuge", + "Top": "oben", + "Top to Bottom": "Oben nach unten", + "Total pixels:": "Gesamtpixel:", + "Translate": "Übersetzen", + "Translate Layer": "Ebene übersetzen", + "Translate error, can not find dictionary:": "Fehler beim Übersetzen, Wörterbuch nicht gefunden:", + "Transparent:": "Transparent:", + "Trim": "Trimmen", + "Trim Layers": "Schichten schneiden", + "Trim borders:": "Rand schneiden:", + "Trim layer:": "Trim-Ebene:", + "Trim white color?": "Trim weiße Farbe?", + "Type:": "Typ:", + "Türkçe": "Türkçe", + "Undo": "Rückgängig machen", + "Unique colors:": "Einzigartige Farben:", + "Up": "Oben", + "Update": "Aktualisieren", + "Update Brush Layer": "Pinselebene aktualisieren", + "Update Pencil Layer": "Bleistiftebene aktualisieren", + "Update guides": "Update-Anleitungen", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Verwenden Sie die Tastenkombination Strg + V zum Einfügen aus der Zwischenablage.", + "V Radius:": "V-Radius:", + "V. Align:": "V. Ausrichten:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Ausführung:", + "Vertical": "Vertikal", + "Vertical Alignment": "Vertikale Ausrichtung", + "Vertical blur:": "Vertikale Unschärfe:", + "Vertical:": "Vertikal:", + "Vibrance": "Dynamik", + "View": "Sicht", + "Vignette": "Vignette", + "ViliusL": "ViliusL", + "Vintage": "Vintage", + "Webcam": "Webcam", + "Webcam #": "Webcam #", + "Website:": "Webseite:", + "Weppy File Format": "Weppy Dateiformat", + "Width (%):": "Breite (%):", + "Width:": "Breite:", + "Windows Bitmap": "Windows Bitmap", + "Word": "Wort", + "Word + Letter": "Wort + Brief", + "Wrap At:": "Wrap At:", + "Wrap:": "Wickeln:", + "Wrong dimensions": "Falsche Abmessungen", + "Wrong file type, must be image or json.": "Falscher Dateityp, muss image oder json sein.", + "X end:": "X Ende:", + "X position:": "X-Position:", + "X start:": "X Start:", + "X-Pro II": "X-Pro II", + "Y end:": "Y Ende:", + "Y position:": "Y-Position:", + "Y start:": "Y Start:", + "You can also drag and drop items into browser.": "Sie können Objekte auch per Drag & Drop in den Browser ziehen.", + "Your browser does not support canvas or JavaScript is not enabled.": "Ihr Browser unterstützt kein Canvas oder JavaScript ist nicht aktiviert.", + "Your browser does not support this format.": "Ihr Browser unterstützt dieses Format nicht.", + "Your search did not match any images.": "Ihre Suche hat keine Bilder gefunden.", + "Zoom": "Zoomen", + "Zoom Blur": "Zoom-Unschärfe", + "Zoom In": "Hineinzoomen", + "Zoom Out": "Herauszoomen", + "Zoom blur": "Zoom-Unschärfe", + "Zoom in": "Hineinzoomen", + "Zoom out": "Herauszoomen", + "Zoom:": "Zoomen:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/el.json b/paintplus/frontend/src/js/languages/el.json new file mode 100644 index 0000000..f504afa --- /dev/null +++ b/paintplus/frontend/src/js/languages/el.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Προέκυψε ένα πρόβλημα κατά την αφαίρεση του ιστορικού αναίρεσης", + "About": "Σχετικά", + "Active": "Ενεργό", + "Aden": "Άντεν", + "Advanced": "Προχωρημένα", + "All": "Όλα", + "Alpha": "Άλφα", + "Alpha:": "Άλφα:", + "Anonymous": "Ανώνυμο", + "Anti aliasing": "Εξομάλυνση", + "Application markup may have changed,": "Η σήμανση της εφαρμογής μπορεί να έχει αλλάξει,", + "Arial": "Άριαλ", + "Arrow": "Βέλος", + "ArrowDown": "Κάτω βέλος", + "ArrowLeft": "Αριστερό βέλος", + "ArrowRight": "Δεξί βέλος", + "ArrowUp": "Πάνω βέλος", + "Author:": "Δημιουργός", + "Auto Adjust Colors": "Αυτόματη ρύθμιση χρωμάτων", + "Auto Kerning": "Αυτόματο διάστημα χαρακτήρων", + "Average:": "Μέσο", + "Backspace": "Οπισθοδρόμηση", + "Base": "Βάση", + "Basic": "Βασικό", + "Black and White": "Μαύρο και Άσπρο", + "Blue": "Μπλε", + "Blue channel:": "Μπλε κανάλι", + "Blueprint": "αποτύπωμα", + "Blur Radius:": "Ακτίνα θολούρας", + "Blur Tool": "Εργαλείο θολούρας", + "Blur power:": "Δύναμη θολούρας", + "Borders": "Όρια", + "Bottom": "Κάτω", + "Bottom to Top": "Κάτω προς πάνω", + "Bounds:": "Περιορισμοί", + "Box": "Κουτί", + "Box Blur": "Κουτί θόλωσης", + "Box blur": "Κουτί θόλωσης", + "Brightness": "Φωτεινότητα", + "Brightness:": "Φωτεινότητα", + "Bulge\/Pinch Tool": "Εργαλείο εξογκώματος \/ Τσιμπήματος", + "Burn": "Καίω", + "Can not animate 1 layer.": "Δεν μπορεί να αναπαράγει ένα επίπεδο", + "Can not find previous layer.": "Δεν μπορεί να βρεί το προηγούμενο επίπεδο", + "Can not use this tool on current layer: image already takes all area.": "Δεν είναι δυνατή η χρήση αυτού του εργαλείου στο τρέχον επίπεδο: η εικόνα καταλαμβάνει ήδη όλη την περιοχή.", + "Cancel": "Ακύρωση", + "Canvas Size": "Μέγεθος καμβά", + "Center": "Κέντρο", + "Center x:": "Κέντρο Χ", + "Center y:": "Κέντρο Υ", + "Center:": "Κέντρο", + "Change Composition": "Αλλαγή Σύνθεσης", + "Change Layer Details": "Λεπτομέρειες αλλαγής επιπέδου", + "Change Opacity": "Αλλαγή αδιαφάνειας", + "Channel:": "Κανάλια", + "Circle": "Κύκλος", + "Clarendon": "Κλαρεντόν", + "Clear": "Καθαρισμός", + "Clear Selection": "Καθαρισμός επιλογής", + "Clone Tool": "Εργαλείο κλωνοποίησης", + "Clone count:": "Μετρητής Κλώνων", + "Clone tool disabled for resized image. Please rasterize first.": "Το εργαλείο κλωνοποίησης απενεργοποιήθηκε για αλλαγή μεγέθους εικόνας. Παρακαλώ ραστεροποιήστε πρώτα.", + "Cloned edges": "Άκρες κλώνου", + "Close": "Κλείσε", + "Color #": "Χρώμα #", + "Color Corrections": "Διορθώσεις χρώματος", + "Color Palette": "Παλέτα χρώματος", + "Color Zoom": "Εστίαση χρώματος", + "Color alpha value can not be zero.": "Η τιμή ΑΛΦΑ στο χρώμα δεν μπορεί να είναι μηδέν", + "Color to Alpha": "Χρώμα σε ΑΛΦΑ", + "Color zoom": "Εστίαση χρώματος", + "Color:": "Χρώμα", + "Colors": "Χρώματα", + "Colors:": "Χρώματα", + "Common Filters": "Κοινά φίλτρα", + "Composition": "Σύνθεση", + "Composition:": "Σύνθεση", + "Content Fill": "Γέμισμα περιεχομένου", + "Contrast": "Αντίθεση", + "Contrast:": "Αντίθεση", + "Convert layer to raster": "Μετατροπή στρώματος σε ράστερ", + "Convert to Raster": "Μετατροπή σε πίνακα τιμών", + "Copy Selection": "Αντιγραφή επιλογής", + "Copy to Clipboard": "Αντιγραφή στο πρόχειρο", + "Courier": "Μεταφορέας", + "Crop Tool": "Εργαλείο αποκοπής", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Η αποκοπή σε περιστραμμένο επίπεδο δεν υποστηρίζεται. Μετατρέψτε το σε πίνακα τιμών για να συνεχίσετε", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl+A", + "Ctrl+C": "Ctrl+C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl+V", + "Ctrl+Y": "Ctrl+Y", + "Ctrl+Z": "Ctrl+Z", + "Current": "Τρέχον", + "Current Color Preview": "Τρέχουσα προεπισκόπηση χρώματος", + "Custom": "Προεπιλεγμένο", + "Data URL": "Δεδομένα URL", + "Data URL:": "Δεδομένα URL", + "Decrease": "Μείωση", + "Decrease Color Depth": "Μείωση βάθους χρώματος", + "Degree:": "Βαθμός", + "Del": "Διαγρ", + "Delete": "Διαγραφή", + "Delete Selection": "Διαγραφή επιλογής", + "Denoise": "Αφαίρεση θορύβου", + "Desaturate Tool": "Εργαλείο αποκορεσμού", + "Description:": "Περιγραφή", + "Deutsch": "Γερμανικά", + "Differences": "Διαφορές", + "Differences Down": "Διαφορές κάτω", + "Direction:": "Κατεύθυνση", + "Dither": "Μείωση παραμόρφωσης σήματος χαμηλού πλάτους", + "Dithering:": "Μείωση παραμόρφωσης σήματος χαμηλού πλάτους", + "Dominant color:": "Κυρίαρχο χρώμα:", + "Dot Screen": "Στίγμα οθόνης", + "Down": "Κάτω", + "Duplicate": "Διπλασίασε", + "Duplicate Layer": "Διπλασίασε επίπεδο", + "Duplicate layer": "Διπλότυπο στρώμα", + "Dynamic": "Δυναμικό", + "Edge": "Αιχμή", + "Edit": "Επεξεργασία", + "Edit text...": "Επεξεργασία κειμένου", + "Effect browser": "Κατάλογος εφέ", + "Effects": "εφέ", + "Effects browser": "Κατάλογος εφέ", + "Email:": "μέιλ", + "Emboss": "Στάμπα", + "Empty selection": "Κενή επιλογή", + "Empty selection or type not image.": "Κενή επιλογή ή όχι τύπος εικόνας", + "Enable autoresize:": "Ενεργοποίηση αυτόματου μεγέθους:", + "End": "Τέλος", + "English": "Αγγλικά", + "English (UK)": "Αγγλικά (Ηνωμένο Βασίλειο)", + "Enrich": "Εμπλουτισμός", + "Enter": "Εισαγωγή", + "Erase Tool": "Εργαλείο διαγραφής", + "Erase on rotate object is disabled. Please rasterize first.": "Η διαγραφή στο περιστρεφόμενο αντικείμενο είναι απενεργοποιημένη. Παρακαλώ ραστεροποιήστε πρώτα.", + "Error": "Σφάλμα", + "Error connecting to service.": "Σφάλμα σύνδεσης σε υπηρεσία", + "Error loading the list of fonts from Google.": "Σφάλμα κατά τη φόρτωση της λίστας γραμματοσειρών από την Google.", + "Error registering service worker": "Σφάλμα εγγραφής σε υπηρεσία", + "Error: can not find filter:": "Σφάλμα: Δεν βρίσκεται το φίλτρο", + "Error: can not find layer with id:": "Σφάλμα: Δεν βρίσκεται το επίπεδο", + "Error: missing details event target": "Σφάλμα: Λείπουν λεπτομέρειες στόχου γεγονότων", + "Error: unknown layer type:": "Σφάλμα: Άγνωστος τύπος επιπέδου", + "Error: unsupported attribute type:": "Σφάλμα: μη υποστηριζόμενος τύπος χαρακτηριστικού:", + "Esc": "Διαφ", + "Escape": "Διαφυγή", + "Español": "Ισπανικά", + "Expand edges": "Διεύρυνση άκρων", + "Exponent:": "Εκθέτης:Ν", + "Export": "Εξαγωγή", + "External": "Εξωτερικός", + "Factor:": "Παράγοντας", + "File": "Αρχείο", + "File name:": "Όνομα αρχείου", + "File size:": "Μέγεθος Αρχείου", + "Fill": "Γέμισμα", + "Fill Tool": "Εργαλείο Γεμίσματος", + "Fit": "Ταίριασμα", + "Fit Window": "Ταίριασμα στο παράθυρο", + "Fit window": "Κατάλληλο παράθυρο", + "Flatten Image": "Επιπεδοποίηση εικόνας", + "Flip": "Αναποδογύρισμα", + "FloydSteinberg-serpentine": "Σερπατίνα Φλόυντ - Στάινμπεργκ", + "Font": "Γραμματοσειρά", + "Français": "Γαλλικά", + "Full HD, 1080p": "Υψηλή ανάλυση 1080ρ", + "Full Screen": "Πλήρης Οθόνη", + "Full layers data": "Πλήρη δεδομένα επιπέδου", + "Gap:": "Κενό", + "Gaussian Blur": "Γκαουσσιανή Θόλωση", + "Gif delay:": "Καθυστέρηση gif", + "Gingham": "Gingham", + "GitHub:": "Github", + "Gradient Radius:": "Ακτίνα κλίσης", + "Grains": "Κόκκοι", + "Graphics Interchange Format": "Μορφή Μεταβαλλόμενων Γραφικών", + "Gray": "Γκρι", + "Grayscale": "Κλίμακα του Γκρι", + "Greek": "Ελληνικά", + "Green": "Πράσινο", + "Green channel:": "Πράσινο Κανάλι", + "Greyscale:": "Κλίμακα του Γκρι", + "Grid": "Πλέγμα", + "Grid on\/off": "Πλέγμα ανοικτό \/ κλειστό", + "Guides": "Οδηγοί", + "Guides enabled.": "Οδηγοί ενεργοί", + "H Radius:": "Οριζόντια Ακτίνα", + "H. Align:": "Οριζόντια Ευθυγράμμιση", + "Heatmap": "Χάρτης θερμότητας", + "Height (%):": "Ύψος (%)", + "Height:": "Ύψος", + "Help": "Βοήθεια", + "Helvetica": "Ελβετικά", + "Hermite": "Ερμητιανό", + "Hex": "Δεκαεξαδικό", + "Hide": "Κρύβω", + "Histogram": "Ιστόγραμμα", + "Histogram:": "Ιστόγραμμα", + "Home": "Αρχική", + "Horizontal": "Οριζόντιο", + "Horizontal Alignment": "Οριζόντια ευθυγράμμιση", + "Horizontal blur:": "Οριζόντια Θόλωση", + "Horizontal:": "Οριζόντιο", + "Hue": "Απόχρωση", + "Hue Rotate": "Περιστροφή απόχρωσης", + "Hue:": "Απόχρωση", + "Image": "Εικόνα", + "Image data with multi-layers. Can be opened using miniPaint -": "Δεδομένα εικόνας με πολλά επίπεδα. Δεν μπορεί να ανοιχτεί με το minipaint", + "Impact": "Επίδραση", + "In proportion:": "Σε αναλογία:", + "Increase": "Αύξηση", + "Information": "Πληροφορίες", + "Inkwell": "Πηγή μελανιού", + "Insert": "Εισαγωγή", + "Insert guides": "Οδηγοί εισαγωγής", + "Insert new layer": "Εισαγάγετε νέο στρώμα", + "Instagram Filters": "Φίλτρα ίνσταγκραμ", + "Invalid Hex Code": "Άκυρος δεκαεξαδικός κωδικός", + "Italiano": "Ιταλικά", + "JPG\/JPEG Format": "Μορφή JPG \/ JPEG", + "Kerning:": "Διάστημα χαρακτήρων", + "Key-Points": "Σημεία - κλειδί", + "KeyU": "Κλειδί υ", + "Keyboard Shortcuts": "Συντομεύσεις πληκτρολογίου", + "Keyword:": "Λέξη - κλειδί", + "Lanczos": "Ζώνη Γλώσσας", + "Landscape": "Τοπίο", + "Language": "Γλώσσα", + "Last modified": "Τελευταία τροποποίηση", + "Layer": "Επίπεδο", + "Layer details": "Λεπτομέρειες επιπέδου", + "Layer is empty.": "Το επίπεδο είναι κενό.", + "Layer is not compatible with resize": "Επίπεδο μη συμβατό με αλλαγή μεγέθους", + "Layer is vector, convert it to raster to apply this tool.": "Το επίπεδο είναι διάνυσμα. Μετατροπή πρώτα σε πίνακα, για εφαρμογή με αυτό το εργαλείο.", + "Layers": "Επίπεδα", + "Layers:": "Επίπεδα:", + "Layout:": "Διάταξη:", + "Left": "Αριστερά", + "Left to Right": "Αριστερά προς δεξιά", + "Level:": "Επίπεδο", + "Levels:": "Επίπεδα", + "Lietuvių": "Lietuviu", + "Lo-fi": "Χαμηλής συχνότητας", + "Luminance:": "Φωτισμός", + "Luminosity": "Ψωτεινότητα", + "Magic Eraser Tool": "Εργαλείο μαγικής σβήστρας", + "Merge Down": "Συγχώνευση προς τα κάτω", + "Merge Layers": "Συγχώνευση επιπέδων", + "Merged": "Συγχωνευμένος", + "Metrics": "Μετρικό", + "Middle": "Μέσαίο", + "Missing at least 1 size parameter.": "Λείπει τουλάχιστον μία παράμετρος μεγέθους", + "Missing permissions to write to Clipboard.cc": "Δεν επιτρέπεται η εγγραφή στο αρχείο πρόχειρου", + "Mode:": "Λειτουργία", + "Module function not found.": "Δεν βρέθηκε η λειτουργία της προσθήκης", + "Modules class not found:": "Δεν βρέθηκε η κλάση της προσθήκης", + "Monospace": "Μονοδιάστημα", + "Mosaic": "Μωσαικό", + "Mouse:": "Ποντίκι", + "Move": "Μετακίνησε", + "Move Layer": "Επίπεδο μετακίνησης", + "Move layer down": "Μετακινήστε το στρώμα προς τα κάτω", + "Move layer up": "Μετακινήστε το στρώμα προς τα πάνω", + "Name:": "Όνομα", + "Negative": "Αρνιτικό", + "New": "Νέο", + "New Bezier Layer": "Νέο στρώμα Bezier", + "New Brush Layer": "Νέο επίπεδο πινέλου", + "New Ellipse Layer": "Νέο επίπεδο έλλειψης", + "New File": "Νέο αρχείο", + "New Gradient Layer": "Νέο επίπεδο κλίσης", + "New Layer": "Νέο επίπεδο", + "New Line Layer": "Νέο επίπεδο γραμμής", + "New Pencil Layer": "Νέο επίπεδο μολυβιού", + "New Polygon Layer": "Νέο στρώμα πολυγώνου", + "New Rectangle Layer": "Νέο επίπεδο ορθογωνίου", + "New Text Layer": "Νέο επίπεδο κειμένου", + "New file": "Νέο αρχείο", + "New from Selection": "Νέο από επιλογή...", + "New layer": "Νέο επίπεδο", + "Next": "Επόμενο", + "Night Vision": "Νυχτερινή όραση", + "None": "Κανένα", + "Nothing is selected.": "Δεν επιλέχθηκε τίποτα", + "Offset X:": "Αντιστάθμισμα Χ", + "Offset Y:": "Αντιστάθμισμα Υ", + "Oil": "Λάδι", + "Ok": "ΟΚ", + "Online image editor.": "Διαδικτυακός Επεξεργαστής εικόνας", + "Opacity": "Αδιαφάνεια", + "Opacity:": "Αδιαφανές", + "Open": "Άνοιγμα", + "Open Data URL": "Άνοιγμα URL δεδομένων", + "Open Directory": "Άνοιγμα καταλόγου", + "Open File": "Άνοιγμα αρχείου", + "Open File Data URL": "Άνοιγμα URL αρχείου δεδομένων", + "Open File URL": "Άνοιγμα URL αρχείου", + "Open File Webcam": "Άνοιγμα αρχείου από κάμερα", + "Open Image": "Άνοιγμα εικόνας", + "Open JSON File": "Άνοιγμα αρχείου JSON", + "Open Test Template": "Άνοιγμα Δοκιμαστικού Υποδείγματος", + "Open URL": "Άνοιγμα URL", + "Open data URL": "Άνοιγμα URL δεδομένων", + "Open from Webcam": "Άνοιγμα από κάμερα", + "Original Size": "Αρχικό μέγεθος", + "PNGTOSVG - Convert Image to SVG": "Μετατροπή εικόνας από PNG σε SVG", + "PageDown": "Σελίδα παρακάτω", + "PageUp": "Σελίδα παραπάνω", + "Palette": "Παλέτα", + "Parameter #1:": "Παράμετρος #1", + "Parameter #2:": "Παράμετρος #2", + "Paste": "Επικόλληση", + "Pencil": "Μολύβι", + "Percentage:": "Ποσοστό:", + "Pixels:": "Πίξελ", + "Placeholder comment for color channels": "Σχόλιο Κατόχου για κανάλια χρωμάτων", + "Placeholder comment for color picker": "Σχόλιο Κατόχου για επιλογέα χρωμάτων", + "Placeholder comment for color swatches": "Σχόλιο Κατόχου για δείγματα χρωμάτων", + "Portable Network Graphics": "Γραφικά φορητού δικτύου", + "Portrait": "Πορτρέτο", + "Português": "Πορτογαλικά", + "Position:": "Θέση", + "Power:": "Δύναμη", + "Preview": "Προεπισκόπηση", + "Previous": "Προηγούμενο", + "Previous layer must be image, convert it to raster to apply this tool.": "Το προηγούμενο επίπεδο πρέπει να είναι εικόνα. Μετατρέψτε το σε πίνακα, για να εφαρμοστεί αυτό το εργαλείο", + "Print": "Εκτύπωση", + "Quality:": "Ποιότητα", + "Quick Load": "Γρήγορο φόρτωμα", + "Quick Save": "Γρήγορη αποθήκευση", + "REMOVE.BG - Remove Image Background": "Αφαίρεση φόντου εικόνας", + "Radial": "Ακτινικό", + "Radial gradient": "Ακτινική κλίση", + "Radius:": "Ακτίνα", + "Range:": "Εύρος", + "Red": "Κόκκινο", + "Red channel:": "Κόκκινο κανάλι", + "Redo": "Επανάλαβε", + "Remove all": "Αφαίρεσε τα όλα", + "Rename": "Μετονομασία", + "Rename Layer": "Μετονομασία επιπέδου", + "Rendered with errors.": "Διεκπεραιώθηκε με σφάλματα", + "Rendering...": "Διεκπεραίωση...", + "Replace Color": "Αντικατάσταση χρώματος", + "Replace color": "Αντικατάσταση χρώματος", + "Replacement:": "Αντικατάσταση", + "Report Issues": "Αναφορά προβλημάτων", + "Reset": "Επαναφορά", + "Resize": "Αλλαγή μεγέθους", + "Resize Boundary": "Αλλαγή μεγέθους ορίων", + "Resize Layer": "Αλλαγή μεγέθους επιπέδου", + "Resize Layers": "Αλλαγή μεγέθους επιπέδων", + "Resize Text Layer": "Αλλαγή μεγέθους επιπέδου κειμένου", + "Resized as background": "Αλλαγή μεγέθους ως φόντο", + "Resized:": "Αλλαγή μεγέθους:", + "Resolution:": "Ανάλυση", + "Restore Alpha": "Επαναφορά τιμής ΑΛΦΑ", + "Right": "Δεξιά", + "Right angle:": "Ορθή γωνία", + "Right to Left": "Δεξιά προς αριστερά", + "Rotate": "Περιστροφή", + "Rotate Layer": "Επίπεδο περιστροφής", + "Rotate is not supported on this type of object. Convert to raster?": "Η περιστροφή δεν υποστηρίζεται σε αυτού του τύπου αντικείμενο. Μετατροπή σε πίνακα;", + "Rotate left": "Περιστροφή αριστερά", + "Rotate:": "Περιστροφή", + "Ruler": "Χάρακας", + "SQUOOSH - Compress and Compare Images": "Συμπίεση και σύγκριση εικόνων", + "Saturate": "Κορεσμός", + "Saturation": "Κορεσμός", + "Saturation:": "Κορεσμός", + "Save As": "Αποθήκευση ως", + "Save As Data URL": "Αποθήκευση ως δεδομένα URL", + "Save as": "Αποθήκευση ως...", + "Save as type:": "Αποθήκευση ως τύπος...", + "Save layers:": "Αποθήκευση επιπέδων", + "Scaling up is not supported in Hermite, using Lanczos.": "Η κλιμάκωση δεν υποστηρίζεται σε ερμητιανό πίνακα, χρησιμοποιόντας LancZos", + "Scroll down": "Κύλιση κάτω", + "Scroll up": "Κύλιση πάνω", + "Search": "Αναζήτηση", + "Search Images": "Αναζήτηση εικόνων", + "Search for Font": "Αναζήτηση γραμματοσειράς", + "Search:": "Αναζήτηση:", + "Select All": "Επιλογή όλων", + "Select Text Layer": "Επιλογή επιπέδου κειμένου", + "Select object tool": "Επιλογή εργαλειου αντικειμένου", + "Selected": "Επιλεγμένο", + "Selection Tool": "Εργαλείο επιλογής ", + "Sensitivity:": "Ευαισθησία", + "Separated": "Διαχωρισμένο", + "Separated (original types)": "Διαχωρισμένοι (πρωτότυποι τύποι)", + "Sepia": "Σέπια", + "Set Image Size": "Θέσε μέγεθος εικόνας", + "Settings": "Ρυθμίσεις", + "Shadow": "Σκιά", + "Shapes": "Σχήματα", + "Shapes (H)": "Σχήματα (Η)", + "Sharpen": "Όξυνση", + "Sharpen Tool": "Εργαλείο όξυνσης", + "Sharpen:": "Όξυνση", + "Shift + S": "Shift + S", + "Shortcut Key:": "Πλήκτρο συντόμευσης", + "Show": "προβολή", + "Show \/ Hide": "Εμφάνισε \/ Κρύψε", + "Show file size:": "Δείξε μέγεθος αρχείου", + "Simple": "Απλό", + "Size is too big, max": "Μέγεθος πέρα του μέγιστου επιτρεπτού", + "Size:": "Μέγεθος", + "Skip - layer must be image.": "Παράλειψη - Το επίπεδο πρέπει να είναι εικόνα", + "Solarize": "Ηλίαση", + "Sorry, cold not load getUserMedia() data:": "Λυπάμαι, δεν μπορώ να φορτώσω τα δεδομένα", + "Sorry, image could not be loaded.": "Λυπάμαι, η εικόνα δεν μπόρεσε να φορτωθεί", + "Sorry, image could not be loaded. Try copy image and paste it.": "Λυπάμαι, η εικόνα δεν μπόρεσε να φορτωθεί. Δοκιμάστε αντιγραφή - επικόλληση.", + "Sorry, image is too big, max 5 MB.": "Λυπάμαι. Πολύ μεγάλη εικόνα. Μέγιστο μέγεθος 5 ΜΒ", + "Source coordinates saved.": "Αποθηκεύτηκαν οι συντεταγμένες της πηγής.", + "Source is empty, right click on image or use long press to save source position.": "Η πηγή είναι άδεια. Κάντε δεξί κλικ στην εικόνα ή πατήστε το παρατεταμένα για να αποθηκεύσετε την θέση της πηγής.", + "Sprites": "Αντικείμενα.", + "Square": "Τετράγωνο", + "Stream:": "Ροή", + "Strength:": "Δύναμη", + "Strict": "Περιορισμός", + "TINYPNG - Compress PNG and JPEG": "Συμπίεση PNG και JPEG", + "Tab": "Στηλοθέτης", + "Tag Image File Format": "Μορφή αρχείου εικόνας", + "Tahoma": "Ταχόμα", + "Target:": "Στόχος", + "The quick brown fox jumps over the lazy dog.": "Η γρήγορη καφέ αλεπού πηδάει πάνω από το τεμπέλικο σκυλί.", + "There": "Εκεί", + "There are no layers behind.": "Δεν υπάρχουν επίπεδα από πίσω", + "There is only 1 layer.": "Υπάρχει μόνο ένα επίπεδο", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Αυτό το επίπεδο πρέπει να περιέχει μια εικόνα. Παρακαλώ μετατρέψτε την σε πίνακα για να εφαρμόσετε αυτό το εργαλέιο.", + "Tilt Shift": "Μετατόπιση κλίσης", + "Times New Roman": "Τimes New Roman", + "Toaster": "Τοστιέρα", + "Toggle": "Εναλλαγή", + "Toggle Color Channels": "Εναλλαγή καναλιών χρώματος", + "Toggle Color Picker": "Εναλλαγή διαλογέα χρώματος", + "Toggle Menu": "Εναλλαγή στο μενού", + "Toggle Swatches": "Εναλλαγή δειγμάτων", + "Tools": "Εργαλεία", + "Top": "Κορυφή", + "Top to Bottom": "Από πάνω προς τα κάτω", + "Total pixels:": "Συνολικά πίξελς", + "Translate": "Μετάφρασε", + "Translate Layer": "Μετάφρασε το επίπεδο", + "Translate error, can not find dictionary:": "Σφάλμα μετάφρασης. Δεν βρίσκεται (σ)το λεξικό ", + "Transparent:": "Διαφανές", + "Trim": "Κούρεμα", + "Trim Layers": "Κούρεμα επιπέδων", + "Trim borders:": "Κούρεμα ορίων", + "Trim layer:": "Κούρεμα επιπέδου", + "Trim white color?": "Κούρεμα λευκού χρώματος;", + "Type:": "Τύπος", + "Türkçe": "Τούρκικα", + "Undo": "Αναίρεση", + "Unique colors:": "Μοναδικά χρώματα", + "Up": "Πάνω", + "Update": "Ενημέρωση", + "Update Brush Layer": "Ενημέρωση επιπέδου πινέλου", + "Update Pencil Layer": "Ενημέρωση επιπέδου μολυβιού", + "Update guides": "Ενημέρωση οδηγιών", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Χρησιμοποίησε τη συντόμευση Ctrl+V για να επικολλήσεις από το πρόχειρο.", + "V Radius:": "Κατακόρυφη ακτίνα", + "V. Align:": "Κατακόρυφη ευθυγράμμιση", + "Valencia": "Βαλένθια", + "Verdana": "Βερντάνα", + "Version:": "Έκδοση", + "Vertical": "Κατακόρυφο", + "Vertical Alignment": "Κατακόρυφη ευθυγράμμιση", + "Vertical blur:": "Κατακόρυφη Θόλωση", + "Vertical:": "Κατακόρυφη", + "Vibrance": "Δόνηση", + "View": "Επισκόπηση", + "Vignette": "Βινιέτα", + "ViliusL": "ViliusL", + "Vintage": "Παλιομοδίτικο", + "Webcam": "Κάμερα", + "Webcam #": "Κάμερα #", + "Website:": "Ιστότοπος", + "Weppy File Format": "Μορφή αρχείου", + "Width (%):": "Πλάτος (%)", + "Width:": "Πλάτος", + "Windows Bitmap": "Windows bitmap (χάρτης bit)", + "Word": "Λέξη", + "Word + Letter": "Λέξη + Γράμμα", + "Wrap At:": "Τύλιξε στο:", + "Wrap:": "Τύλιξε", + "Wrong dimensions": "Λάθος διαστάσεις", + "Wrong file type, must be image or json.": "Λάθος τύπος αρχείου. Πρέπει να είναι εικόνα ή JSON", + "X end:": "Χ τέλος", + "X position:": "Χ θέση", + "X start:": "Χ αρχή", + "X-Pro II": "Χ Προ ΙΙ", + "Y end:": "Υ τέλος", + "Y position:": "Υ θέση", + "Y start:": "Υ αρχή", + "You can also drag and drop items into browser.": "Μπορείς επίσης να σύρεις αντικείμενα μέσα στο φυλλομετρητή", + "Your browser does not support canvas or JavaScript is not enabled.": "Ο φυλλομετρητής σου δεν υποστηρίζει καμβά ή Javascript.", + "Your browser does not support this format.": "Ο φυλλομετρηρής σου δεν υποστηρίζει αυτή τη μορφή", + "Your search did not match any images.": "Η αναζήτηση σου δεν ταίριαξε με καμία εικόνα", + "Zoom": "Ζούμ (μεγένθυση - σμίκρυνση)", + "Zoom Blur": "Εστίαση Θολούρας", + "Zoom In": "Μεγένθυση", + "Zoom Out": "Σμίκρυνση", + "Zoom blur": "Εστίαση Θολούρας", + "Zoom in": "Μεγένθυση", + "Zoom out": "Σμίκρυνση", + "Zoom:": "Εστίαση (Ζούμ)" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/empty.json b/paintplus/frontend/src/js/languages/empty.json new file mode 100644 index 0000000..7635943 --- /dev/null +++ b/paintplus/frontend/src/js/languages/empty.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "", + "About": "", + "Active": "", + "Aden": "", + "Advanced": "", + "All": "", + "Alpha": "", + "Alpha:": "", + "Anonymous": "", + "Anti aliasing": "", + "Application markup may have changed,": "", + "Arial": "", + "Arrow": "", + "ArrowDown": "", + "ArrowLeft": "", + "ArrowRight": "", + "ArrowUp": "", + "Author:": "", + "Auto Adjust Colors": "", + "Auto Kerning": "", + "Average:": "", + "Backspace": "", + "Base": "", + "Basic": "", + "Black and White": "", + "Blue": "", + "Blue channel:": "", + "Blueprint": "", + "Blur Radius:": "", + "Blur Tool": "", + "Blur power:": "", + "Borders": "", + "Bottom": "", + "Bottom to Top": "", + "Bounds:": "", + "Box": "", + "Box Blur": "", + "Box blur": "", + "Brightness": "", + "Brightness:": "", + "Bulge\/Pinch Tool": "", + "Burn": "", + "Can not animate 1 layer.": "", + "Can not find previous layer.": "", + "Can not use this tool on current layer: image already takes all area.": "", + "Cancel": "", + "Canvas Size": "", + "Center": "", + "Center x:": "", + "Center y:": "", + "Center:": "", + "Change Composition": "", + "Change Layer Details": "", + "Change Opacity": "", + "Channel:": "", + "Circle": "", + "Clarendon": "", + "Clear": "", + "Clear Selection": "", + "Clone Tool": "", + "Clone count:": "", + "Clone tool disabled for resized image. Please rasterize first.": "", + "Cloned edges": "", + "Close": "", + "Color #": "", + "Color Corrections": "", + "Color Palette": "", + "Color Zoom": "", + "Color alpha value can not be zero.": "", + "Color to Alpha": "", + "Color zoom": "", + "Color:": "", + "Colors": "", + "Colors:": "", + "Common Filters": "", + "Composition": "", + "Composition:": "", + "Content Fill": "", + "Contrast": "", + "Contrast:": "", + "Convert layer to raster": "", + "Convert to Raster": "", + "Copy Selection": "", + "Copy to Clipboard": "", + "Courier": "", + "Crop Tool": "", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "", + "Ctrl + C": "", + "Ctrl+A": "", + "Ctrl+C": "", + "Ctrl+P": "", + "Ctrl+V": "", + "Ctrl+Y": "", + "Ctrl+Z": "", + "Current": "", + "Current Color Preview": "", + "Custom": "", + "Data URL": "", + "Data URL:": "", + "Decrease": "", + "Decrease Color Depth": "", + "Degree:": "", + "Del": "", + "Delete": "", + "Delete Selection": "", + "Denoise": "", + "Desaturate Tool": "", + "Description:": "", + "Deutsch": "", + "Differences": "", + "Differences Down": "", + "Direction:": "", + "Dither": "", + "Dithering:": "", + "Dominant color:": "", + "Dot Screen": "", + "Down": "", + "Duplicate": "", + "Duplicate Layer": "", + "Duplicate layer": "", + "Dynamic": "", + "Edge": "", + "Edit": "", + "Edit text...": "", + "Effect browser": "", + "Effects": "", + "Effects browser": "", + "Email:": "", + "Emboss": "", + "Empty selection": "", + "Empty selection or type not image.": "", + "Enable autoresize:": "", + "End": "", + "English": "", + "English (UK)": "", + "Enrich": "", + "Enter": "", + "Erase Tool": "", + "Erase on rotate object is disabled. Please rasterize first.": "", + "Error": "", + "Error connecting to service.": "", + "Error loading the list of fonts from Google.": "", + "Error registering service worker": "", + "Error: can not find filter:": "", + "Error: can not find layer with id:": "", + "Error: missing details event target": "", + "Error: unknown layer type:": "", + "Error: unsupported attribute type:": "", + "Esc": "", + "Escape": "", + "Español": "", + "Expand edges": "", + "Exponent:": "", + "Export": "", + "External": "", + "Factor:": "", + "File": "", + "File name:": "", + "File size:": "", + "Fill": "", + "Fill Tool": "", + "Fit": "", + "Fit Window": "", + "Fit window": "", + "Flatten Image": "", + "Flip": "", + "FloydSteinberg-serpentine": "", + "Font": "", + "Français": "", + "Full HD, 1080p": "", + "Full Screen": "", + "Full layers data": "", + "Gap:": "", + "Gaussian Blur": "", + "Gif delay:": "", + "Gingham": "", + "GitHub:": "", + "Gradient Radius:": "", + "Grains": "", + "Graphics Interchange Format": "", + "Gray": "", + "Grayscale": "", + "Greek": "", + "Green": "", + "Green channel:": "", + "Greyscale:": "", + "Grid": "", + "Grid on\/off": "", + "Guides": "", + "Guides enabled.": "", + "H Radius:": "", + "H. Align:": "", + "Heatmap": "", + "Height (%):": "", + "Height:": "", + "Help": "", + "Helvetica": "", + "Hermite": "", + "Hex": "", + "Hide": "", + "Histogram": "", + "Histogram:": "", + "Home": "", + "Horizontal": "", + "Horizontal Alignment": "", + "Horizontal blur:": "", + "Horizontal:": "", + "Hue": "", + "Hue Rotate": "", + "Hue:": "", + "Image": "", + "Image data with multi-layers. Can be opened using miniPaint -": "", + "Impact": "", + "In proportion:": "", + "Increase": "", + "Information": "", + "Inkwell": "", + "Insert": "", + "Insert guides": "", + "Insert new layer": "", + "Instagram Filters": "", + "Invalid Hex Code": "", + "Italiano": "", + "JPG\/JPEG Format": "", + "Kerning:": "", + "Key-Points": "", + "KeyU": "", + "Keyboard Shortcuts": "", + "Keyword:": "", + "Lanczos": "", + "Landscape": "", + "Language": "", + "Last modified": "", + "Layer": "", + "Layer details": "", + "Layer is empty.": "", + "Layer is not compatible with resize": "", + "Layer is vector, convert it to raster to apply this tool.": "", + "Layers": "", + "Layers:": "", + "Layout:": "", + "Left": "", + "Left to Right": "", + "Level:": "", + "Levels:": "", + "Lietuvių": "", + "Lo-fi": "", + "Luminance:": "", + "Luminosity": "", + "Magic Eraser Tool": "", + "Merge Down": "", + "Merge Layers": "", + "Merged": "", + "Metrics": "", + "Middle": "", + "Missing at least 1 size parameter.": "", + "Missing permissions to write to Clipboard.cc": "", + "Mode:": "", + "Module function not found.": "", + "Modules class not found:": "", + "Monospace": "", + "Mosaic": "", + "Mouse:": "", + "Move": "", + "Move Layer": "", + "Move layer down": "", + "Move layer up": "", + "Name:": "", + "Negative": "", + "New": "", + "New Bezier Layer": "", + "New Brush Layer": "", + "New Ellipse Layer": "", + "New File": "", + "New Gradient Layer": "", + "New Layer": "", + "New Line Layer": "", + "New Pencil Layer": "", + "New Polygon Layer": "", + "New Rectangle Layer": "", + "New Text Layer": "", + "New file": "", + "New from Selection": "", + "New layer": "", + "Next": "", + "Night Vision": "", + "None": "", + "Nothing is selected.": "", + "Offset X:": "", + "Offset Y:": "", + "Oil": "", + "Ok": "", + "Online image editor.": "", + "Opacity": "", + "Opacity:": "", + "Open": "", + "Open Data URL": "", + "Open Directory": "", + "Open File": "", + "Open File Data URL": "", + "Open File URL": "", + "Open File Webcam": "", + "Open Image": "", + "Open JSON File": "", + "Open Test Template": "", + "Open URL": "", + "Open data URL": "", + "Open from Webcam": "", + "Original Size": "", + "PNGTOSVG - Convert Image to SVG": "", + "PageDown": "", + "PageUp": "", + "Palette": "", + "Parameter #1:": "", + "Parameter #2:": "", + "Paste": "", + "Pencil": "", + "Percentage:": "", + "Pixels:": "", + "Placeholder comment for color channels": "", + "Placeholder comment for color picker": "", + "Placeholder comment for color swatches": "", + "Portable Network Graphics": "", + "Portrait": "", + "Português": "", + "Position:": "", + "Power:": "", + "Preview": "", + "Previous": "", + "Previous layer must be image, convert it to raster to apply this tool.": "", + "Print": "", + "Quality:": "", + "Quick Load": "", + "Quick Save": "", + "REMOVE.BG - Remove Image Background": "", + "Radial": "", + "Radial gradient": "", + "Radius:": "", + "Range:": "", + "Red": "", + "Red channel:": "", + "Redo": "", + "Remove all": "", + "Rename": "", + "Rename Layer": "", + "Rendered with errors.": "", + "Rendering...": "", + "Replace Color": "", + "Replace color": "", + "Replacement:": "", + "Report Issues": "", + "Reset": "", + "Resize": "", + "Resize Boundary": "", + "Resize Layer": "", + "Resize Layers": "", + "Resize Text Layer": "", + "Resized as background": "", + "Resized:": "", + "Resolution:": "", + "Restore Alpha": "", + "Right": "", + "Right angle:": "", + "Right to Left": "", + "Rotate": "", + "Rotate Layer": "", + "Rotate is not supported on this type of object. Convert to raster?": "", + "Rotate left": "", + "Rotate:": "", + "Ruler": "", + "SQUOOSH - Compress and Compare Images": "", + "Saturate": "", + "Saturation": "", + "Saturation:": "", + "Save As": "", + "Save As Data URL": "", + "Save as": "", + "Save as type:": "", + "Save layers:": "", + "Scaling up is not supported in Hermite, using Lanczos.": "", + "Scroll down": "", + "Scroll up": "", + "Search": "", + "Search Images": "", + "Search for Font": "", + "Search:": "", + "Select All": "", + "Select Text Layer": "", + "Select object tool": "", + "Selected": "", + "Selection Tool": "", + "Sensitivity:": "", + "Separated": "", + "Separated (original types)": "", + "Sepia": "", + "Set Image Size": "", + "Settings": "", + "Shadow": "", + "Shapes": "", + "Shapes (H)": "", + "Sharpen": "", + "Sharpen Tool": "", + "Sharpen:": "", + "Shift + S": "", + "Shortcut Key:": "", + "Show": "", + "Show \/ Hide": "", + "Show file size:": "", + "Simple": "", + "Size is too big, max": "", + "Size:": "", + "Skip - layer must be image.": "", + "Solarize": "", + "Sorry, cold not load getUserMedia() data:": "", + "Sorry, image could not be loaded.": "", + "Sorry, image could not be loaded. Try copy image and paste it.": "", + "Sorry, image is too big, max 5 MB.": "", + "Source coordinates saved.": "", + "Source is empty, right click on image or use long press to save source position.": "", + "Sprites": "", + "Square": "", + "Stream:": "", + "Strength:": "", + "Strict": "", + "TINYPNG - Compress PNG and JPEG": "", + "Tab": "", + "Tag Image File Format": "", + "Tahoma": "", + "Target:": "", + "The quick brown fox jumps over the lazy dog.": "", + "There": "", + "There are no layers behind.": "", + "There is only 1 layer.": "", + "This layer must contain an image. Please convert it to raster to apply this tool.": "", + "Tilt Shift": "", + "Times New Roman": "", + "Toaster": "", + "Toggle": "", + "Toggle Color Channels": "", + "Toggle Color Picker": "", + "Toggle Menu": "", + "Toggle Swatches": "", + "Tools": "", + "Top": "", + "Top to Bottom": "", + "Total pixels:": "", + "Translate": "", + "Translate Layer": "", + "Translate error, can not find dictionary:": "", + "Transparent:": "", + "Trim": "", + "Trim Layers": "", + "Trim borders:": "", + "Trim layer:": "", + "Trim white color?": "", + "Type:": "", + "Türkçe": "", + "Undo": "", + "Unique colors:": "", + "Up": "", + "Update": "", + "Update Brush Layer": "", + "Update Pencil Layer": "", + "Update guides": "", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "", + "V Radius:": "", + "V. Align:": "", + "Valencia": "", + "Verdana": "", + "Version:": "", + "Vertical": "", + "Vertical Alignment": "", + "Vertical blur:": "", + "Vertical:": "", + "Vibrance": "", + "View": "", + "Vignette": "", + "ViliusL": "", + "Vintage": "", + "Webcam": "", + "Webcam #": "", + "Website:": "", + "Weppy File Format": "", + "Width (%):": "", + "Width:": "", + "Windows Bitmap": "", + "Word": "", + "Word + Letter": "", + "Wrap At:": "", + "Wrap:": "", + "Wrong dimensions": "", + "Wrong file type, must be image or json.": "", + "X end:": "", + "X position:": "", + "X start:": "", + "X-Pro II": "", + "Y end:": "", + "Y position:": "", + "Y start:": "", + "You can also drag and drop items into browser.": "", + "Your browser does not support canvas or JavaScript is not enabled.": "", + "Your browser does not support this format.": "", + "Your search did not match any images.": "", + "Zoom": "", + "Zoom Blur": "", + "Zoom In": "", + "Zoom Out": "", + "Zoom blur": "", + "Zoom in": "", + "Zoom out": "", + "Zoom:": "" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/es.json b/paintplus/frontend/src/js/languages/es.json new file mode 100644 index 0000000..7b42758 --- /dev/null +++ b/paintplus/frontend/src/js/languages/es.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Ocurrió un problema al eliminar el historial de deshacer. Eso", + "About": "Acerca de", + "Active": "Activo", + "Aden": "Adén", + "Advanced": "Avanzado", + "All": "Todas", + "Alpha": "Alfa", + "Alpha:": "Alfa:", + "Anonymous": "Anónimo", + "Anti aliasing": "Anti aliasing", + "Application markup may have changed,": "Es posible que el marcado de la aplicación haya cambiado,", + "Arial": "Arial", + "Arrow": "Flecha", + "ArrowDown": "ArrowDown", + "ArrowLeft": "Flecha Izquierda", + "ArrowRight": "Flecha Derecha", + "ArrowUp": "Flecha arriba", + "Author:": "Autor:", + "Auto Adjust Colors": "Ajuste automático de colores", + "Auto Kerning": "Kerning automático", + "Average:": "Promedio:", + "Backspace": "Retroceso", + "Base": "Base", + "Basic": "BASIC", + "Black and White": "En blanco y negro", + "Blue": "Azul", + "Blue channel:": "Canal azul:", + "Blueprint": "Plano", + "Blur Radius:": "Blur Radio:", + "Blur Tool": "Herramienta de desenfoque", + "Blur power:": "Desenfoque de poder:", + "Borders": "Bordes", + "Bottom": "Fondo", + "Bottom to Top": "Abajo hacia arriba", + "Bounds:": "Límites:", + "Box": "Caja", + "Box Blur": "Caja de desenfoque", + "Box blur": "Caja de desenfoque", + "Brightness": "Brillo", + "Brightness:": "Brillo:", + "Bulge\/Pinch Tool": "Herramienta de abultamiento \/ pellizco", + "Burn": "Quemar", + "Can not animate 1 layer.": "No se puede animar 1 capa.", + "Can not find previous layer.": "No se puede encontrar la capa anterior.", + "Can not use this tool on current layer: image already takes all area.": "No se puede utilizar esta herramienta en la capa actual: la imagen ya ocupa toda el área.", + "Cancel": "Cancelar", + "Canvas Size": "Tamaño del lienzo", + "Center": "Centrar", + "Center x:": "Centro x:", + "Center y:": "Centro y:", + "Center:": "Centrar:", + "Change Composition": "Cambiar composición", + "Change Layer Details": "Cambiar los detalles de la capa", + "Change Opacity": "Cambiar la opacidad", + "Channel:": "Canal:", + "Circle": "Circulo", + "Clarendon": "Letras gruesas a la media", + "Clear": "Claro", + "Clear Selection": "Selección clara", + "Clone Tool": "Herramienta de clonación", + "Clone count:": "Recuento de clones", + "Clone tool disabled for resized image. Please rasterize first.": "Herramienta de clonación deshabilitada para imágenes redimensionadas. Rasterice primero.", + "Cloned edges": "Bordes clonados", + "Close": "Cerca", + "Color #": "Color #", + "Color Corrections": "Correcciones de color", + "Color Palette": "Paleta de color", + "Color Zoom": "Zoom de color", + "Color alpha value can not be zero.": "El valor alfa del color no puede ser cero.", + "Color to Alpha": "Color a alfa", + "Color zoom": "Zoom a color", + "Color:": "Color:", + "Colors": "Colores", + "Colors:": "Colores:", + "Common Filters": "Filtros comunes", + "Composition": "Composición", + "Composition:": "Composición:", + "Content Fill": "Relleno de contenido", + "Contrast": "Contraste", + "Contrast:": "Contraste:", + "Convert layer to raster": "Convertir capa a ráster", + "Convert to Raster": "Convertir a trama", + "Copy Selection": "Copiar selección", + "Copy to Clipboard": "Copiar al portapapeles", + "Courier": "mensajero", + "Crop Tool": "Herramienta de recorte", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "No se admite el recorte en la capa rotada. Conviértalo en ráster para continuar.", + "Ctrl + C": "Ctrl+C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "Corriente", + "Current Color Preview": "Vista previa del color actual", + "Custom": "Personalizado", + "Data URL": "URL de datos", + "Data URL:": "URL de datos:", + "Decrease": "Disminución", + "Decrease Color Depth": "Disminuir la profundidad de color", + "Degree:": "La licenciatura:", + "Del": "Del", + "Delete": "Borrar", + "Delete Selection": "Eliminar selección", + "Denoise": "Denoise", + "Desaturate Tool": "Herramienta de desaturar", + "Description:": "Descripción:", + "Deutsch": "Alemán", + "Differences": "Diferencias", + "Differences Down": "Diferencias hacia abajo", + "Direction:": "Dirección:", + "Dither": "Vacilar", + "Dithering:": "Dithering:", + "Dominant color:": "Color dominante:", + "Dot Screen": "Pantalla de puntos", + "Down": "Abajo", + "Duplicate": "Duplicar", + "Duplicate Layer": "Duplicar capa", + "Duplicate layer": "Duplicar capa", + "Dynamic": "Dinámica", + "Edge": "Borde", + "Edit": "Editar", + "Edit text...": "Editar texto...", + "Effect browser": "Navegador de efectos", + "Effects": "Efectos", + "Effects browser": "Navegador de efectos", + "Email:": "Email:", + "Emboss": "Realzar", + "Empty selection": "Selección vacía", + "Empty selection or type not image.": "Vaciar selección o escribir no imagen.", + "Enable autoresize:": "Habilitar tamaño automático:", + "End": "Fin", + "English": "Inglés", + "English (UK)": "Inglés del Reino Unido)", + "Enrich": "Enriquecer", + "Enter": "Entrar", + "Erase Tool": "Herramienta de borrado", + "Erase on rotate object is disabled. Please rasterize first.": "Borrar al rotar objeto está deshabilitado. Rasterice primero.", + "Error": "Error", + "Error connecting to service.": "Error al conectarse al servicio.", + "Error loading the list of fonts from Google.": "Error al cargar la lista de fuentes de Google.", + "Error registering service worker": "Error al registrar al trabajador del servicio", + "Error: can not find filter:": "Error: no se puede encontrar el filtro:", + "Error: can not find layer with id:": "Error: no se puede encontrar la capa con id:", + "Error: missing details event target": "Error: falta el objetivo del evento de detalles", + "Error: unknown layer type:": "Error: tipo de capa desconocido:", + "Error: unsupported attribute type:": "Error: tipo de atributo no admitido:", + "Esc": "Esc", + "Escape": "Escapar", + "Español": "English", + "Expand edges": "Expandir bordes", + "Exponent:": "Exponente:", + "Export": "Exportar", + "External": "Externo", + "Factor:": "Factor:", + "File": "Archivo", + "File name:": "Nombre del archivo:", + "File size:": "Tamaño del archivo:", + "Fill": "Llenar", + "Fill Tool": "Herramienta de relleno", + "Fit": "Ajuste", + "Fit Window": "Ajustar ventana", + "Fit window": "Ajustar ventana", + "Flatten Image": "Imagen aplanada", + "Flip": "Dar la vuelta", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentina", + "Font": "Fuente", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "Pantalla completa", + "Full layers data": "Datos de capas completas", + "Gap:": "Brecha:", + "Gaussian Blur": "Desenfoque gaussiano", + "Gif delay:": "Retraso Gif:", + "Gingham": "Guingán", + "GitHub:": "GitHub:", + "Gradient Radius:": "Radio de gradiente:", + "Grains": "Granos", + "Graphics Interchange Format": "formato de gráficos intercambeable", + "Gray": "gris", + "Grayscale": "Escala de grises", + "Greek": "Griego", + "Green": "Verde", + "Green channel:": "Canal verde:", + "Greyscale:": "Escala de grises:", + "Grid": "Cuadrícula", + "Grid on\/off": "Grid on \/ off", + "Guides": "Guías", + "Guides enabled.": "Guías habilitadas.", + "H Radius:": "H Radio:", + "H. Align:": "H. Alinear:", + "Heatmap": "Mapa de calor", + "Height (%):": "Altura (%):", + "Height:": "Altura:", + "Help": "Ayuda", + "Helvetica": "Helvética", + "Hermite": "Hermite", + "Hex": "Maleficio", + "Hide": "Esconder", + "Histogram": "Histograma", + "Histogram:": "Histograma:", + "Home": "Casa", + "Horizontal": "Horizontal", + "Horizontal Alignment": "Alineación horizontal", + "Horizontal blur:": "Desenfoque horizontal:", + "Horizontal:": "Horizontal:", + "Hue": "Matiz", + "Hue Rotate": "Hue Rotate", + "Hue:": "Matiz:", + "Image": "Imagen", + "Image data with multi-layers. Can be opened using miniPaint -": "Datos de imagen con varias capas. Se puede abrir usando miniPaint -", + "Impact": "Impacto", + "In proportion:": "En proporción:", + "Increase": "Incrementar", + "Information": "Información", + "Inkwell": "Tintero", + "Insert": "Insertar", + "Insert guides": "Insertar guías", + "Insert new layer": "Insertar nueva capa", + "Instagram Filters": "Filtros de Instagram", + "Invalid Hex Code": "Código hexadecimal no válido", + "Italiano": "Italiano", + "JPG\/JPEG Format": "Formato JPG \/ JPEG", + "Kerning:": "Interletrado:", + "Key-Points": "Puntos clave", + "KeyU": "ClaveU", + "Keyboard Shortcuts": "Atajos de teclado", + "Keyword:": "Palabra clave:", + "Lanczos": "Lanczos", + "Landscape": "Paisaje", + "Language": "Idioma", + "Last modified": "Última modificación", + "Layer": "Capa", + "Layer details": "Detalles de la capa", + "Layer is empty.": "La capa está vacía.", + "Layer is not compatible with resize": "La capa no es compatible con el cambio de tamaño", + "Layer is vector, convert it to raster to apply this tool.": "La capa es vectorial, conviértala en ráster para aplicar esta herramienta.", + "Layers": "Capas", + "Layers:": "Capas:", + "Layout:": "Disposición:", + "Left": "Izquierda", + "Left to Right": "De izquierda a derecha", + "Level:": "Nivel:", + "Levels:": "Niveles:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Luminancia:", + "Luminosity": "Luminosidad", + "Magic Eraser Tool": "Herramienta de borrador mágico", + "Merge Down": "Fusionar", + "Merge Layers": "Fusionar capas", + "Merged": "Fusionado", + "Metrics": "Métrica", + "Middle": "Medio", + "Missing at least 1 size parameter.": "Falta al menos 1 parámetro de tamaño.", + "Missing permissions to write to Clipboard.cc": "Permisos faltantes para escribir en Clipboard.cc", + "Mode:": "Modo:", + "Module function not found.": "Función del módulo no encontrada.", + "Modules class not found:": "Clase de módulos no encontrada:", + "Monospace": "Monoespacio", + "Mosaic": "Mosaico", + "Mouse:": "Ratón:", + "Move": "Movimiento", + "Move Layer": "Mover capa", + "Move layer down": "Mover capa hacia abajo", + "Move layer up": "Mover capa hacia arriba", + "Name:": "Nombre:", + "Negative": "Negativo", + "New": "Nuevo", + "New Bezier Layer": "Nueva capa Bézier", + "New Brush Layer": "Nueva capa de pincel", + "New Ellipse Layer": "Nueva capa de elipse", + "New File": "Archivo nuevo", + "New Gradient Layer": "Nueva capa de degradado", + "New Layer": "Nueva capa", + "New Line Layer": "Nueva capa de línea", + "New Pencil Layer": "Nueva capa de lápiz", + "New Polygon Layer": "Nueva capa de polígono", + "New Rectangle Layer": "Nueva capa de rectángulo", + "New Text Layer": "Nueva capa de texto", + "New file": "Archivo nuevo", + "New from Selection": "Nuevo de la selección", + "New layer": "Nueva capa", + "Next": "Próximo", + "Night Vision": "Vision nocturna", + "None": "Ninguna", + "Nothing is selected.": "Nada está seleccionado.", + "Offset X:": "Compensación X:", + "Offset Y:": "Desplazamiento Y:", + "Oil": "Petróleo", + "Ok": "De acuerdo", + "Online image editor.": "Editor de imágenes en línea", + "Opacity": "Opacidad", + "Opacity:": "Opacidad:", + "Open": "Abierto", + "Open Data URL": "URL de datos abiertos", + "Open Directory": "Directorio abierto", + "Open File": "Abrir documento", + "Open File Data URL": "Abrir URL de datos de archivo", + "Open File URL": "Abrir URL de archivo", + "Open File Webcam": "Cámara web de archivos abiertos", + "Open Image": "Abrir imagen", + "Open JSON File": "Abrir archivo JSON", + "Open Test Template": "Plantilla de prueba abierta", + "Open URL": "URL abierta", + "Open data URL": "URL de datos abiertos", + "Open from Webcam": "Abrir desde la webcam", + "Original Size": "Tamaño original", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Convertir imagen a SVG", + "PageDown": "Página abajo", + "PageUp": "Página arriba", + "Palette": "Paleta", + "Parameter #1:": "Parámetro # 1:", + "Parameter #2:": "Parámetro # 2:", + "Paste": "Pegar", + "Pencil": "Lápiz", + "Percentage:": "Porcentaje:", + "Pixels:": "Píxeles:", + "Placeholder comment for color channels": "Comentario de marcador de posición para canales de color", + "Placeholder comment for color picker": "Comentario de marcador de posición para el selector de color", + "Placeholder comment for color swatches": "Comentario de marcador de posición para muestras de color", + "Portable Network Graphics": "Gráficos de red portátiles", + "Portrait": "Retrato", + "Português": "Português", + "Position:": "Posición:", + "Power:": "Poder:", + "Preview": "Avance", + "Previous": "Anterior", + "Previous layer must be image, convert it to raster to apply this tool.": "La capa anterior debe ser una imagen, conviértala a raster para aplicar esta herramienta.", + "Print": "Impresión", + "Quality:": "Calidad:", + "Quick Load": "Carga rápida", + "Quick Save": "Guardado rápido", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Eliminar fondo de imagen", + "Radial": "Radial", + "Radial gradient": "Gradiente radial", + "Radius:": "Radio:", + "Range:": "Distancia:", + "Red": "rojo", + "Red channel:": "Canal rojo:", + "Redo": "Rehacer", + "Remove all": "Eliminar todo", + "Rename": "Rebautizar", + "Rename Layer": "Cambiar nombre de capa", + "Rendered with errors.": "Rendido con errores.", + "Rendering...": "Representación...", + "Replace Color": "Reemplazar color", + "Replace color": "Reemplazar color", + "Replacement:": "Reemplazo:", + "Report Issues": "Informar problemas", + "Reset": "Reiniciar", + "Resize": "Cambiar el tamaño", + "Resize Boundary": "Cambiar tamaño de límite", + "Resize Layer": "Cambiar el tamaño de la capa", + "Resize Layers": "Cambiar el tamaño de las capas", + "Resize Text Layer": "Cambiar el tamaño de la capa de texto", + "Resized as background": "Redimensionado como fondo", + "Resized:": "Redimensionado:", + "Resolution:": "Resolución:", + "Restore Alpha": "Restaurar alfa", + "Right": "Derecha", + "Right angle:": "Ángulo recto:", + "Right to Left": "De derecha a izquierda", + "Rotate": "Girar", + "Rotate Layer": "Girar capa", + "Rotate is not supported on this type of object. Convert to raster?": "Girar no es compatible con este tipo de objeto. Convertir a raster?", + "Rotate left": "Girar a la izquierda", + "Rotate:": "Girar:", + "Ruler": "Gobernante", + "SQUOOSH - Compress and Compare Images": "SQUOOSH: comprime y compara imágenes", + "Saturate": "Saturar", + "Saturation": "Saturación", + "Saturation:": "Saturación:", + "Save As": "Guardar como", + "Save As Data URL": "Guardar como URL de datos", + "Save as": "Guardar como", + "Save as type:": "Guardar como tipo:", + "Save layers:": "Guardar capas:", + "Scaling up is not supported in Hermite, using Lanczos.": "Hermite no admite la ampliación mediante Lanczos.", + "Scroll down": "Desplazarse hacia abajo", + "Scroll up": "Desplazarse hacia arriba", + "Search": "Buscar", + "Search Images": "Buscar imágenes", + "Search for Font": "Buscar fuente", + "Search:": "Buscar:", + "Select All": "Seleccionar todo", + "Select Text Layer": "Seleccionar capa de texto", + "Select object tool": "Seleccionar herramienta de objeto", + "Selected": "Seleccionado", + "Selection Tool": "Herramienta de selección", + "Sensitivity:": "Sensibilidad:", + "Separated": "Apartado", + "Separated (original types)": "Separados (tipos originales)", + "Sepia": "Sepia", + "Set Image Size": "Establecer tamaño de imagen", + "Settings": "Configuraciones", + "Shadow": "Sombra", + "Shapes": "Formas", + "Shapes (H)": "Formas (H)", + "Sharpen": "Afilar", + "Sharpen Tool": "Herramienta de afilado", + "Sharpen:": "Afilar:", + "Shift + S": "Mayús + S", + "Shortcut Key:": "Tecla de acceso directo:", + "Show": "Espectáculo", + "Show \/ Hide": "Mostrar ocultar", + "Show file size:": "Mostrar tamaño de archivo:", + "Simple": "Sencillo", + "Size is too big, max": "El tamaño es demasiado grande, máximo", + "Size:": "Tamaño:", + "Skip - layer must be image.": "Omitir: la capa debe ser una imagen.", + "Solarize": "Solarizar", + "Sorry, cold not load getUserMedia() data:": "Lo sentimos, no se cargan los datos de getUserMedia () en frío:", + "Sorry, image could not be loaded.": "Lo sentimos, no se pudo cargar la imagen.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Lo sentimos, la imagen no se pudo cargar. Intenta copiar la imagen y pégala.", + "Sorry, image is too big, max 5 MB.": "Lo sentimos, la imagen es demasiado grande, máximo 5 MB.", + "Source coordinates saved.": "Se guardaron las coordenadas de origen.", + "Source is empty, right click on image or use long press to save source position.": "La fuente está vacía, haga clic con el botón derecho en la imagen o presione prolongadamente para guardar la posición de la fuente.", + "Sprites": "Sprites", + "Square": "Cuadrado", + "Stream:": "Corriente:", + "Strength:": "Fuerza:", + "Strict": "Estricto", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Comprimir PNG y JPEG", + "Tab": "Lengüeta", + "Tag Image File Format": "Formato de archivo de imagen de etiqueta", + "Tahoma": "Tahoma", + "Target:": "Objetivo:", + "The quick brown fox jumps over the lazy dog.": "El veloz zorro marrón salta sobre el perro perezoso.", + "There": "Allí", + "There are no layers behind.": "No hay capas detrás", + "There is only 1 layer.": "Solo hay 1 capa", + "This layer must contain an image. Please convert it to raster to apply this tool.": "La capa debe ser una imagen, conviértala a raster para aplicar esta herramienta.", + "Tilt Shift": "Cambio de inclinación", + "Times New Roman": "Times New Roman", + "Toaster": "Tostadora", + "Toggle": "Palanca", + "Toggle Color Channels": "Alternar canales de color", + "Toggle Color Picker": "Alternar selector de color", + "Toggle Menu": "Alternar menú", + "Toggle Swatches": "Alternar muestras", + "Tools": "Herramientas", + "Top": "Parte superior", + "Top to Bottom": "De arriba hacia abajo", + "Total pixels:": "Píxeles totales:", + "Translate": "Traducir", + "Translate Layer": "Traducir capa", + "Translate error, can not find dictionary:": "Error de traducción, no se puede encontrar el diccionario:", + "Transparent:": "Transparente:", + "Trim": "Recortar", + "Trim Layers": "Recortar capas", + "Trim borders:": "Recortar bordes:", + "Trim layer:": "Capa de ajuste:", + "Trim white color?": "Recortar el color blanco?", + "Type:": "Tipo:", + "Türkçe": "Türkçe", + "Undo": "Deshacer", + "Unique colors:": "Colores únicos:", + "Up": "Arriba", + "Update": "Actualizar", + "Update Brush Layer": "Actualizar capa de pincel", + "Update Pencil Layer": "Actualizar capa de lápiz", + "Update guides": "Guías de actualización", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Use el atajo de teclado Ctrl + V para pegar desde el Portapapeles.", + "V Radius:": "V Radio:", + "V. Align:": "V. Alinear:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Versión:", + "Vertical": "Vertical", + "Vertical Alignment": "Alineamiento vertical", + "Vertical blur:": "Desenfoque vertical:", + "Vertical:": "Vertical:", + "Vibrance": "Vibrance", + "View": "Vista", + "Vignette": "Viñeta", + "ViliusL": "ViliusL", + "Vintage": "Vendimia", + "Webcam": "Cámara web", + "Webcam #": "Cámara web #", + "Website:": "Sitio web:", + "Weppy File Format": "Formato de archivo Weppy", + "Width (%):": "Ancho (%):", + "Width:": "Anchura:", + "Windows Bitmap": "Mapa de bits de Windows", + "Word": "Palabra", + "Word + Letter": "Palabra + Letra", + "Wrap At:": "Envolver en:", + "Wrap:": "Envolver:", + "Wrong dimensions": "Dimensiones incorrectas", + "Wrong file type, must be image or json.": "Tipo de archivo incorrecto, debe ser imagen o json.", + "X end:": "X final:", + "X position:": "Posición X:", + "X start:": "X inicio:", + "X-Pro II": "X-Pro II", + "Y end:": "Final de Y:", + "Y position:": "Posición Y:", + "Y start:": "Y comienza:", + "You can also drag and drop items into browser.": "También puede arrastrar y soltar elementos en el navegador.", + "Your browser does not support canvas or JavaScript is not enabled.": "Su navegador no admite lienzo o JavaScript no está habilitado.", + "Your browser does not support this format.": "Su navegador no es compatible con este formato.", + "Your search did not match any images.": "Su búsqueda no coincide con ninguna imagen.", + "Zoom": "Enfocar", + "Zoom Blur": "Desenfoque de zoom", + "Zoom In": "Acercarse", + "Zoom Out": "Disminuir el zoom", + "Zoom blur": "Borroso de zoom", + "Zoom in": "Acercarse", + "Zoom out": "Disminuir el zoom", + "Zoom:": "Enfocar:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/fr.json b/paintplus/frontend/src/js/languages/fr.json new file mode 100644 index 0000000..7133927 --- /dev/null +++ b/paintplus/frontend/src/js/languages/fr.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Un problème est survenu lors de la suppression de l'historique des annulations. Il", + "About": "A propos", + "Active": "actif", + "Aden": "Aden", + "Advanced": "Avancé", + "All": "Tout", + "Alpha": "Alpha", + "Alpha:": "Alpha :", + "Anonymous": "Anonyme", + "Anti aliasing": "Anticrénelage", + "Application markup may have changed,": "Le balisage de l'application peut avoir changé,", + "Arial": "Arial", + "Arrow": "Flèche", + "ArrowDown": "Flèche vers le bas", + "ArrowLeft": "Flèche Gauche", + "ArrowRight": "FlècheDroite", + "ArrowUp": "Flèche vers le haut", + "Author:": "Auteur :", + "Auto Adjust Colors": "Ajuster automatiquement les couleurs", + "Auto Kerning": "Crénage automatique", + "Average:": "Moyenne :", + "Backspace": "Retour arrière", + "Base": "Base", + "Basic": "Basique", + "Black and White": "Noir et blanc", + "Blue": "Bleu", + "Blue channel:": "Niveau de bleu :", + "Blueprint": "Plan", + "Blur Radius:": "Rayon de floutage :", + "Blur Tool": "Outil de floutage", + "Blur power:": "Puissance de flou:", + "Borders": "Cadres", + "Bottom": "Bas", + "Bottom to Top": "De bas en haut", + "Bounds:": "Bornes:", + "Box": "Boîte", + "Box Blur": "Box Blur", + "Box blur": "Box flou", + "Brightness": "Luminosité", + "Brightness:": "Luminosité :", + "Bulge\/Pinch Tool": "Outil de renflement \/ pincement", + "Burn": "Brûler", + "Can not animate 1 layer.": "Impossible d'animer une couche.", + "Can not find previous layer.": "Impossible de trouver la couche précédente.", + "Can not use this tool on current layer: image already takes all area.": "Impossible d'utiliser cet outil sur le calque actuel : l'image occupe déjà toute la zone.", + "Cancel": "Annuler", + "Canvas Size": "Taille de la toile", + "Center": "Centre", + "Center x:": "Centrage x :", + "Center y:": "Centrage y :", + "Center:": "Centre :", + "Change Composition": "Changer la composition", + "Change Layer Details": "Modifier les détails du calque", + "Change Opacity": "Changer l'opacité", + "Channel:": "Niveau :", + "Circle": "Cercle", + "Clarendon": "Clarendon", + "Clear": "Effacer", + "Clear Selection": "Effacer la sélection", + "Clone Tool": "Outil de clonage", + "Clone count:": "Nombre de clones:", + "Clone tool disabled for resized image. Please rasterize first.": "Outil de clonage désactivé pour l'image redimensionnée. Veuillez d'abord pixelliser.", + "Cloned edges": "Bords clonés", + "Close": "Fermer", + "Color #": "Couleur #", + "Color Corrections": "Correction des couleurs", + "Color Palette": "Palette de couleurs", + "Color Zoom": "Eclat", + "Color alpha value can not be zero.": "La valeur alpha de la couleur ne peut pas être nulle.", + "Color to Alpha": "Rendre transparent", + "Color zoom": "Zoom couleur", + "Color:": "Couleur :", + "Colors": "Couleurs", + "Colors:": "Couleurs :", + "Common Filters": "Filtres communs", + "Composition": "Composition", + "Composition:": "Composition :", + "Content Fill": "Remplissage de contenu", + "Contrast": "Contraste", + "Contrast:": "Contraste :", + "Convert layer to raster": "Convertir le calque en raster", + "Convert to Raster": "Convertir en raster", + "Copy Selection": "Copier", + "Copy to Clipboard": "Copier dans le presse-papier", + "Courier": "Courier", + "Crop Tool": "Outil de recadrage", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Le recadrage sur le calque pivoté n'est pas pris en charge. Convertissez-le en raster pour continuer.", + "Ctrl + C": "Ctrl+C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "Actuel", + "Current Color Preview": "Aperçu de la couleur actuelle", + "Custom": "Personnalisé", + "Data URL": "URL de données", + "Data URL:": "URL de données:", + "Decrease": "Diminution", + "Decrease Color Depth": "Postériser", + "Degree:": "Degré:", + "Del": "Supp", + "Delete": "Supprimer", + "Delete Selection": "Effacer la sélection", + "Denoise": "Réduire le bruit", + "Desaturate Tool": "Outil de désaturation", + "Description:": "Description :", + "Deutsch": "Deutsch", + "Differences": "Détection des bords", + "Differences Down": "Détection des bords...", + "Direction:": "Direction:", + "Dither": "Ajouter du bruit", + "Dithering:": "Trame :", + "Dominant color:": "Couleur dominante:", + "Dot Screen": "Demi-teinte", + "Down": "Vers le bas", + "Duplicate": "Dupliquer", + "Duplicate Layer": "Dupliquer le calque", + "Duplicate layer": "Dupliquer le calque", + "Dynamic": "Dynamique", + "Edge": "Détection des bords", + "Edit": "Edition", + "Edit text...": "Éditer le texte...", + "Effect browser": "Navigateur d'effets", + "Effects": "Effets", + "Effects browser": "Navigateur d'effets", + "Email:": "Email :", + "Emboss": "Embossage", + "Empty selection": "Sélection vide", + "Empty selection or type not image.": "Sélection vide ou tapez pas d'image.", + "Enable autoresize:": "Activer le redimensionnement automatique :", + "End": "Fin", + "English": "Anglais", + "English (UK)": "Anglais Royaume-Uni)", + "Enrich": "Améliorer la netteté", + "Enter": "Entrer", + "Erase Tool": "Outil d'effacement", + "Erase on rotate object is disabled. Please rasterize first.": "L'effacement lors de la rotation de l'objet est désactivé. Veuillez d'abord pixelliser.", + "Error": "Erreur", + "Error connecting to service.": "Erreur lors de la connexion au service.", + "Error loading the list of fonts from Google.": "Erreur lors du chargement de la liste des polices de Google.", + "Error registering service worker": "Erreur lors de l'enregistrement du technicien de service", + "Error: can not find filter:": "Erreur: impossible de trouver le filtre:", + "Error: can not find layer with id:": "Erreur: impossible de trouver la couche avec l'ID:", + "Error: missing details event target": "Erreur: cible des événements manquants de détails", + "Error: unknown layer type:": "Erreur: type de couche inconnu:", + "Error: unsupported attribute type:": "Erreur : type d'attribut non pris en charge :", + "Esc": "Esc", + "Escape": "Échapper", + "Español": "Espagnol", + "Expand edges": "Développer les bords", + "Exponent:": "Exposant :", + "Export": "Exporter", + "External": "Externe", + "Factor:": "Facteur :", + "File": "Fichier", + "File name:": "Nom de fichier :", + "File size:": "Taille du fichier :", + "Fill": "Remplir", + "Fill Tool": "Outil de remplissage", + "Fit": "Fenêtre", + "Fit Window": "Remplir la fenêtre", + "Fit window": "Ajuster la fenêtre", + "Flatten Image": "Fusionner tous les calques", + "Flip": "Retourner", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentine", + "Font": "Police de caractère", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "Plein écran", + "Full layers data": "Données de couches complètes", + "Gap:": "Ecart :", + "Gaussian Blur": "Flou gaussien", + "Gif delay:": "Gif délai:", + "Gingham": "Vichy", + "GitHub:": "GitHub :", + "Gradient Radius:": "Rayon du dégradé :", + "Grains": "Grains", + "Graphics Interchange Format": "Format d'échange graphique", + "Gray": "Gris", + "Grayscale": "Niveaux de gris", + "Greek": "grec", + "Green": "Vert", + "Green channel:": "Niveau de vert :", + "Greyscale:": "Noir et blanc :", + "Grid": "Grille", + "Grid on\/off": "Grille activée \/ désactivée", + "Guides": "Guides", + "Guides enabled.": "Guides activés.", + "H Radius:": "Rayon H :", + "H. Align:": "H. Aligner:", + "Heatmap": "Zones chaudes", + "Height (%):": "Hauteur (%) :", + "Height:": "Hauteur :", + "Help": "Aide", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Cacher", + "Histogram": "Histogramme", + "Histogram:": "Histogramme :", + "Home": "Accueil", + "Horizontal": "Horizontalement", + "Horizontal Alignment": "Alignement horizontal", + "Horizontal blur:": "Flou horizontal:", + "Horizontal:": "Horizontal:", + "Hue": "Teinte", + "Hue Rotate": "Hue Rotate", + "Hue:": "Teinte :", + "Image": "Image", + "Image data with multi-layers. Can be opened using miniPaint -": "Données d'image avec plusieurs couches. Peut être ouvert en utilisant miniPaint -", + "Impact": "Impact", + "In proportion:": "En proportion:", + "Increase": "Augmenter", + "Information": "Informations", + "Inkwell": "Encrier", + "Insert": "Insérer", + "Insert guides": "Insérer des guides", + "Insert new layer": "Insérer un nouveau calque", + "Instagram Filters": "Filtres Instagram", + "Invalid Hex Code": "Code hexadécimal non valide", + "Italiano": "Italien", + "JPG\/JPEG Format": "Format JPG \/ JPEG", + "Kerning:": "Crénage:", + "Key-Points": "Points clés", + "KeyU": "CléU", + "Keyboard Shortcuts": "Raccourcis clavier", + "Keyword:": "Mot-clé:", + "Lanczos": "Lanczos", + "Landscape": "Paysage", + "Language": "Langue", + "Last modified": "Dernière mise à jour", + "Layer": "Couche", + "Layer details": "Détails de la couche", + "Layer is empty.": "Le calque est vide.", + "Layer is not compatible with resize": "Le calque n'est pas compatible avec le redimensionnement", + "Layer is vector, convert it to raster to apply this tool.": "Le calque est vectoriel, convertissez-le en raster pour appliquer cet outil.", + "Layers": "Calques", + "Layers:": "Couches:", + "Layout:": "Mise en page:", + "Left": "à gauche", + "Left to Right": "De gauche à droite", + "Level:": "Niveau :", + "Levels:": "Niveau :", + "Lietuvių": "Lituanien", + "Lo-fi": "Lo-fi", + "Luminance:": "Luminance :", + "Luminosity": "Luminosité", + "Magic Eraser Tool": "Outil Gomme magique", + "Merge Down": "Fusionner avec le calque inférieur", + "Merge Layers": "Fusionner les calques", + "Merged": "Fusionné", + "Metrics": "Métrique", + "Middle": "Milieu", + "Missing at least 1 size parameter.": "Il manque au moins 1 paramètre de taille.", + "Missing permissions to write to Clipboard.cc": "Autorisations manquantes pour écrire dans Clipboard.cc", + "Mode:": "Mode :", + "Module function not found.": "Fonction du module introuvable.", + "Modules class not found:": "Classe de modules introuvable:", + "Monospace": "Monospace", + "Mosaic": "Mosaïque", + "Mouse:": "Souris :", + "Move": "Déplacer", + "Move Layer": "Déplacer le calque", + "Move layer down": "Déplacer le calque vers le bas", + "Move layer up": "Déplacer le calque vers le haut", + "Name:": "Nom :", + "Negative": "Négatif", + "New": "Nouveau...", + "New Bezier Layer": "Nouvelle couche de Bézier", + "New Brush Layer": "Nouveau calque de pinceau", + "New Ellipse Layer": "Nouveau calque Ellipse", + "New File": "Nouveau fichier", + "New Gradient Layer": "Nouveau calque de dégradé", + "New Layer": "Nouvelle Couche", + "New Line Layer": "Nouvelle couche de ligne", + "New Pencil Layer": "Nouveau calque de crayon", + "New Polygon Layer": "Nouveau calque de polygone", + "New Rectangle Layer": "Nouveau calque rectangle", + "New Text Layer": "Nouveau calque de texte", + "New file": "Nouveau fichier", + "New from Selection": "Nouveau à partir de la sélection", + "New layer": "Nouveau calque", + "Next": "Suivant", + "Night Vision": "Vision nocturne", + "None": "Aucun", + "Nothing is selected.": "Rien n'est sélectionné.", + "Offset X:": "Décalage X:", + "Offset Y:": "Décalage Y:", + "Oil": "Peinture à l'huile", + "Ok": "OK", + "Online image editor.": "Éditeur d'image en ligne.", + "Opacity": "Opacité", + "Opacity:": "Opacité:", + "Open": "Ouvrir", + "Open Data URL": "URL de données ouvertes", + "Open Directory": "Ouvrir le répertoire", + "Open File": "Fichier ouvert", + "Open File Data URL": "Ouvrir l'URL des données de fichier", + "Open File URL": "Ouvrir l'URL du fichier", + "Open File Webcam": "Ouvrir le fichier webcam", + "Open Image": "Ouvrir l'image", + "Open JSON File": "Ouvrez le fichier JSON", + "Open Test Template": "Modèle de test ouvert", + "Open URL": "Ouvrir depuis le Web", + "Open data URL": "URL de données ouvertes", + "Open from Webcam": "Ouvrir depuis la webcam", + "Original Size": "Format original", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Convertir l'image en SVG", + "PageDown": "Bas de page", + "PageUp": "Page Up", + "Palette": "Palette", + "Parameter #1:": "Paramètre n ° 1:", + "Parameter #2:": "Paramètre n ° 2:", + "Paste": "Coller", + "Pencil": "Crayon", + "Percentage:": "Pourcentage:", + "Pixels:": "Pixels :", + "Placeholder comment for color channels": "Commentaire d'espace réservé pour les canaux de couleur", + "Placeholder comment for color picker": "Commentaire d'espace réservé pour le sélecteur de couleurs", + "Placeholder comment for color swatches": "Commentaire d'espace réservé pour les échantillons de couleur", + "Portable Network Graphics": "Portable Network Graphics", + "Portrait": "Portrait", + "Português": "Português", + "Position:": "Position:", + "Power:": "Tol.<\/abbr> :", + "Preview": "Aperçu", + "Previous": "précédent", + "Previous layer must be image, convert it to raster to apply this tool.": "La couche précédente doit être une image, la convertir en raster pour appliquer cet outil.", + "Print": "Imprimer", + "Quality:": "Qualité :", + "Quick Load": "Chargement rapide", + "Quick Save": "Sauvegarde rapide", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Supprimer l'arrière-plan de l'image", + "Radial": "Radial", + "Radial gradient": "Gradient radial", + "Radius:": "Rayon :", + "Range:": "Gamme :", + "Red": "Rouge", + "Red channel:": "Niveau de rouge :", + "Redo": "Refaire", + "Remove all": "Enlever tout", + "Rename": "Renommer", + "Rename Layer": "Renommer le calque", + "Rendered with errors.": "Rendu avec des erreurs.", + "Rendering...": "Le rendu...", + "Replace Color": "Remplacer une couleur", + "Replace color": "Remplacer une couleur", + "Replacement:": "Remplacement :", + "Report Issues": "Signaler un problème", + "Reset": "Réinitialiser", + "Resize": "Redimensionner", + "Resize Boundary": "Redimensionner la limite", + "Resize Layer": "Redimensionner le calque", + "Resize Layers": "Redimensionner les calques", + "Resize Text Layer": "Redimensionner le calque de texte", + "Resized as background": "Redimensionné comme arrière-plan", + "Resized:": "Redimensionné :", + "Resolution:": "Taille :", + "Restore Alpha": "Restaurer le niveau alpha", + "Right": "à droite", + "Right angle:": "Angle droit:", + "Right to Left": "De droite à gauche", + "Rotate": "Faire pivoter", + "Rotate Layer": "Faire pivoter le calque", + "Rotate is not supported on this type of object. Convert to raster?": "La rotation n'est pas prise en charge sur ce type d'objet. Convertir en raster?", + "Rotate left": "Faire pivoter à gauche", + "Rotate:": "Tourner:", + "Ruler": "Règle", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Compresser et comparer des images", + "Saturate": "Saturer", + "Saturation": "Saturation", + "Saturation:": "Saturation :", + "Save As": "Enregistrer sous", + "Save As Data URL": "Enregistrer comme URL de données", + "Save as": "Enregistrer sous", + "Save as type:": "Enregistrer comme type :", + "Save layers:": "Enregistrer les calques :", + "Scaling up is not supported in Hermite, using Lanczos.": "La mise à l'échelle n'est pas prise en charge dans Hermite, à l'aide de Lanczos.", + "Scroll down": "Défiler vers le bas", + "Scroll up": "Défiler", + "Search": "Chercher", + "Search Images": "Rechercher des images", + "Search for Font": "Rechercher une police", + "Search:": "Recherche:", + "Select All": "Sélectionner tout", + "Select Text Layer": "Sélectionnez le calque de texte", + "Select object tool": "Déplacer les pixels sélectionnés", + "Selected": "Choisi", + "Selection Tool": "Outil de sélection", + "Sensitivity:": "Sensibilité :", + "Separated": "Séparé", + "Separated (original types)": "Séparé (types originaux)", + "Sepia": "Vieille photo", + "Set Image Size": "Définir la taille de l'image", + "Settings": "Paramètres", + "Shadow": "Ombre", + "Shapes": "Formes", + "Shapes (H)": "Formes (H)", + "Sharpen": "Améliorer la netteté", + "Sharpen Tool": "Outil Sharpen", + "Sharpen:": "Netteté :", + "Shift + S": "Maj + S", + "Shortcut Key:": "Touche de raccourci:", + "Show": "Montrer", + "Show \/ Hide": "Montrer \/ Cacher", + "Show file size:": "Calculer la taille du fichier :", + "Simple": "Simple", + "Size is too big, max": "La taille est trop grande, max", + "Size:": "Taille :", + "Skip - layer must be image.": "Skip-layer doit être image.", + "Solarize": "Solariser", + "Sorry, cold not load getUserMedia() data:": "Désolé, ne chargez pas les données getUserMedia () à froid:", + "Sorry, image could not be loaded.": "Désolé, l'image n'a pas pu être chargée.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Désolé, l'image n'a pas pu être chargée. Essayez de la copier dans le presse-papier et de la coller à la place.", + "Sorry, image is too big, max 5 MB.": "Désolé, l'image est trop grande (5MB max).", + "Source coordinates saved.": "Coordonnées source enregistrées.", + "Source is empty, right click on image or use long press to save source position.": "La source est vide, cliquez avec le bouton droit sur l'image ou appuyez longuement pour enregistrer la position de la source.", + "Sprites": "Sprites", + "Square": "Carré", + "Stream:": "Courant:", + "Strength:": "Force :", + "Strict": "Strict", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Compresser PNG et JPEG", + "Tab": "Languette", + "Tag Image File Format": "Format de fichier image de balise", + "Tahoma": "Tahoma", + "Target:": "Cible :", + "The quick brown fox jumps over the lazy dog.": "Le renard brun rapide saute par-dessus le chien paresseux.", + "There": "Là", + "There are no layers behind.": "Il n'y a pas de couches derrière.", + "There is only 1 layer.": "Il n'y a qu'une seule couche.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Le calque doit être image, le convertir en raster pour appliquer cet outil.", + "Tilt Shift": "Flou artistique", + "Times New Roman": "Times New Roman", + "Toaster": "Grille-pain", + "Toggle": "Basculer", + "Toggle Color Channels": "Basculer les canaux de couleur", + "Toggle Color Picker": "Basculer le sélecteur de couleurs", + "Toggle Menu": "Basculer le menu", + "Toggle Swatches": "Basculer les nuances", + "Tools": "Outils", + "Top": "Haut", + "Top to Bottom": "De haut en bas", + "Total pixels:": "Nombre de pixels :", + "Translate": "Traduire", + "Translate Layer": "Traduire le calque", + "Translate error, can not find dictionary:": "Erreur de traduction, impossible de trouver le dictionnaire :", + "Transparent:": "Transparence :", + "Trim": "Rogner l'image", + "Trim Layers": "Couper les couches", + "Trim borders:": "Couper les bordures:", + "Trim layer:": "Couche de garniture:", + "Trim white color?": "Taillez la couleur blanche?", + "Type:": "Taper:", + "Türkçe": "Türkçe", + "Undo": "Annuler", + "Unique colors:": "Couleurs uniques :", + "Up": "Vers le haut", + "Update": "Mise à jour", + "Update Brush Layer": "Mettre à jour le calque de pinceau", + "Update Pencil Layer": "Mettre à jour le calque de crayon", + "Update guides": "Guides de mise à jour", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Utilisez le raccourci clavier Ctrl + V pour coller à partir du Presse-papiers.", + "V Radius:": "Rayon V :", + "V. Align:": "V. Aligner:", + "Valencia": "Valence", + "Verdana": "Verdana", + "Version:": "Version:", + "Vertical": "Verticalement", + "Vertical Alignment": "Alignement vertical", + "Vertical blur:": "Flou vertical:", + "Vertical:": "Verticale:", + "Vibrance": "Vibrance", + "View": "Voir", + "Vignette": "Vignette", + "ViliusL": "ViliusL", + "Vintage": "Vintage", + "Webcam": "Webcam", + "Webcam #": "Webcam #", + "Website:": "Site Internet:", + "Weppy File Format": "Format de fichier Weppy", + "Width (%):": "Largeur (%) :", + "Width:": "Largeur :", + "Windows Bitmap": "Bitmap Windows", + "Word": "Mot", + "Word + Letter": "Mot + Lettre", + "Wrap At:": "Envelopper à:", + "Wrap:": "Emballage:", + "Wrong dimensions": "Mauvaises dimensions", + "Wrong file type, must be image or json.": "Mauvais type de fichier, image ou json attendu.", + "X end:": "Fin X :", + "X position:": "Position x :", + "X start:": "Début X :", + "X-Pro II": "X-Pro II", + "Y end:": "Fin Y :", + "Y position:": "Position y :", + "Y start:": "Début Y :", + "You can also drag and drop items into browser.": "Vous pouvez également faire glisser et déposer des éléments dans le navigateur.", + "Your browser does not support canvas or JavaScript is not enabled.": "Votre navigateur ne supporte pas le canevas ou JavaScript n'est pas activé.", + "Your browser does not support this format.": "Votre navigateur ne supporte pas ce format.", + "Your search did not match any images.": "Votre recherche ne correspond à aucune image.", + "Zoom": "Zoom", + "Zoom Blur": "Zoom Flou", + "Zoom In": "Agrandir", + "Zoom Out": "Réduire", + "Zoom blur": "Zoom flou", + "Zoom in": "Zoomer", + "Zoom out": "Dézoomer", + "Zoom:": "Zoom :" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/it.json b/paintplus/frontend/src/js/languages/it.json new file mode 100644 index 0000000..7967ae6 --- /dev/null +++ b/paintplus/frontend/src/js/languages/it.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Si è verificato un problema durante la rimozione della cronologia degli annullamenti. It", + "About": "Di", + "Active": "Attivo", + "Aden": "Aden", + "Advanced": "Avanzate", + "All": "Tutti", + "Alpha": "Alfa", + "Alpha:": "Alfa:", + "Anonymous": "Anonimo", + "Anti aliasing": "Anti aliasing", + "Application markup may have changed,": "Il markup dell'applicazione potrebbe essere cambiato", + "Arial": "Arial", + "Arrow": "Freccia", + "ArrowDown": "ArrowDown", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + "ArrowUp": "ArrowUp", + "Author:": "Autore:", + "Auto Adjust Colors": "Regola automaticamente i colori", + "Auto Kerning": "Crenatura automatica", + "Average:": "Media:", + "Backspace": "Backspace", + "Base": "Base", + "Basic": "Di base", + "Black and White": "Bianco e nero", + "Blue": "Blu", + "Blue channel:": "Canale blu:", + "Blueprint": "Planimetria", + "Blur Radius:": "Sfocatura raggio:", + "Blur Tool": "Strumento di sfocatura", + "Blur power:": "Sfocatura:", + "Borders": "frontiere", + "Bottom": "Parte inferiore", + "Bottom to Top": "Dal basso verso l'alto", + "Bounds:": "Limiti:", + "Box": "Scatola", + "Box Blur": "Box Blur", + "Box blur": "Scatola sfocatura", + "Brightness": "Luminosità", + "Brightness:": "Luminosità:", + "Bulge\/Pinch Tool": "Strumento rigonfiamento \/ pizzico", + "Burn": "Bruciare", + "Can not animate 1 layer.": "Impossibile animare 1 livello.", + "Can not find previous layer.": "Impossibile trovare il livello precedente.", + "Can not use this tool on current layer: image already takes all area.": "Impossibile utilizzare questo strumento sul livello corrente: l'immagine occupa già tutta l'area.", + "Cancel": "Annulla", + "Canvas Size": "Dimensioni della tela", + "Center": "Centro", + "Center x:": "Centro x:", + "Center y:": "Centro y:", + "Center:": "Centro:", + "Change Composition": "Cambia composizione", + "Change Layer Details": "Cambia i dettagli del livello", + "Change Opacity": "Cambia opacità", + "Channel:": "Canale:", + "Circle": "Cerchio", + "Clarendon": "Clarendon", + "Clear": "Chiaro", + "Clear Selection": "Cancella selezione", + "Clone Tool": "Strumento clone", + "Clone count:": "Conteggio dei cloni:", + "Clone tool disabled for resized image. Please rasterize first.": "Strumento clone disabilitato per l'immagine ridimensionata. Per favore rasterizza prima.", + "Cloned edges": "Bordi clonati", + "Close": "Vicino", + "Color #": "Colore #", + "Color Corrections": "Correzioni di colore", + "Color Palette": "Palette dei colori", + "Color Zoom": "Zoom a colori", + "Color alpha value can not be zero.": "Il valore alfa del colore non può essere zero.", + "Color to Alpha": "Colore ad alfa", + "Color zoom": "Zoom a colori", + "Color:": "Colore:", + "Colors": "Colori", + "Colors:": "Colori:", + "Common Filters": "Filtri comuni", + "Composition": "Composizione", + "Composition:": "Composizione:", + "Content Fill": "Riempimento del contenuto", + "Contrast": "Contrasto", + "Contrast:": "Contrasto:", + "Convert layer to raster": "Converti livello in raster", + "Convert to Raster": "Converti in raster", + "Copy Selection": "Copia selezione", + "Copy to Clipboard": "Copia negli appunti", + "Courier": "Corriere", + "Crop Tool": "Strumento di ritaglio", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Il ritaglio su livello ruotato non è supportato. Convertilo in raster per continuare.", + "Ctrl + C": "CTRL+C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "CTRL+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "attuale", + "Current Color Preview": "Anteprima colore corrente", + "Custom": "costume", + "Data URL": "URL dei dati", + "Data URL:": "URL dei dati:", + "Decrease": "Diminuire", + "Decrease Color Depth": "Diminuisci la profondità del colore", + "Degree:": "Grado:", + "Del": "del", + "Delete": "Elimina", + "Delete Selection": "Elimina selezione", + "Denoise": "Denoise", + "Desaturate Tool": "Strumento di desatura", + "Description:": "Descrizione:", + "Deutsch": "Tedesco", + "Differences": "differenze", + "Differences Down": "Differenze giù", + "Direction:": "Direzione:", + "Dither": "oscillare", + "Dithering:": "dithering:", + "Dominant color:": "Colore dominante:", + "Dot Screen": "Schermo a punti", + "Down": "Giù", + "Duplicate": "Duplicare", + "Duplicate Layer": "Livello duplicato", + "Duplicate layer": "Livello duplicato", + "Dynamic": "Dinamico", + "Edge": "Bordo", + "Edit": "modificare", + "Edit text...": "Modifica il testo...", + "Effect browser": "Browser effetti", + "Effects": "effetti", + "Effects browser": "Browser degli effetti", + "Email:": "E-mail:", + "Emboss": "rilievo", + "Empty selection": "Selezione vuota", + "Empty selection or type not image.": "Selezione vuota o tipo non immagine.", + "Enable autoresize:": "Abilita ridimensionamento automatico:", + "End": "Fine", + "English": "Inglese", + "English (UK)": "Inglese (Regno Unito)", + "Enrich": "Arricchire", + "Enter": "accedere", + "Erase Tool": "Strumento di cancellazione", + "Erase on rotate object is disabled. Please rasterize first.": "La cancellazione durante la rotazione dell'oggetto è disabilitata. Per favore rasterizza prima.", + "Error": "Errore", + "Error connecting to service.": "Errore durante la connessione al servizio.", + "Error loading the list of fonts from Google.": "Errore durante il caricamento dell'elenco dei caratteri da Google.", + "Error registering service worker": "Errore durante la registrazione dell'operatore del servizio", + "Error: can not find filter:": "Errore: impossibile trovare il filtro:", + "Error: can not find layer with id:": "Errore: impossibile trovare il livello con ID:", + "Error: missing details event target": "Errore: manca il bersaglio dell'evento dettagli", + "Error: unknown layer type:": "Errore: tipo di livello sconosciuto:", + "Error: unsupported attribute type:": "Errore: tipo di attributo non supportato:", + "Esc": "Esc", + "Escape": "Fuga", + "Español": "Español", + "Expand edges": "Espandi i bordi", + "Exponent:": "Esponente:", + "Export": "Esportare", + "External": "Esterno", + "Factor:": "Fattore:", + "File": "File", + "File name:": "Nome del file:", + "File size:": "Dimensione del file:", + "Fill": "Riempire", + "Fill Tool": "Strumento di riempimento", + "Fit": "In forma", + "Fit Window": "Finestra adatta", + "Fit window": "Adatta la finestra", + "Flatten Image": "Immagine piatta", + "Flip": "Flip", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentina", + "Font": "Font", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "A schermo intero", + "Full layers data": "Dati a strati completi", + "Gap:": "Gap:", + "Gaussian Blur": "Sfocatura gaussiana", + "Gif delay:": "Ritardo Gif:", + "Gingham": "Percalle", + "GitHub:": "GitHub:", + "Gradient Radius:": "Raggio di pendenza:", + "Grains": "Grani", + "Graphics Interchange Format": "Formato di interscambio grafico", + "Gray": "Grigio", + "Grayscale": "Scala di grigi", + "Greek": "greco", + "Green": "verde", + "Green channel:": "Canale Verde:", + "Greyscale:": "Scala di grigi:", + "Grid": "Griglia", + "Grid on\/off": "Griglia on \/ off", + "Guides": "Guide", + "Guides enabled.": "Guide abilitate.", + "H Radius:": "Raggio H:", + "H. Align:": "H. Allinea:", + "Heatmap": "Mappa di calore", + "Height (%):": "Altezza (%):", + "Height:": "Altezza:", + "Help": "Aiuto", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Esadecimale", + "Hide": "Nascondere", + "Histogram": "Istogramma", + "Histogram:": "Istogramma:", + "Home": "Casa", + "Horizontal": "Orizzontale", + "Horizontal Alignment": "Allineamento orizzontale", + "Horizontal blur:": "Sfocatura orizzontale:", + "Horizontal:": "Orizzontale:", + "Hue": "Hue", + "Hue Rotate": "Tonalità Ruota", + "Hue:": "Hue:", + "Image": "Immagine", + "Image data with multi-layers. Can be opened using miniPaint -": "Dati immagine con multi-layer. Può essere aperto usando miniPaint -", + "Impact": "urto", + "In proportion:": "In proporzione:", + "Increase": "Aumentare", + "Information": "Informazione", + "Inkwell": "Calamaio", + "Insert": "Inserire", + "Insert guides": "Inserire le guide", + "Insert new layer": "Inserisci un nuovo livello", + "Instagram Filters": "Filtri Instagram", + "Invalid Hex Code": "Codice esadecimale non valido", + "Italiano": "Italiano", + "JPG\/JPEG Format": "Formato JPG \/ JPEG", + "Kerning:": "Crenatura:", + "Key-Points": "Punti chiave", + "KeyU": "KeyU", + "Keyboard Shortcuts": "Tasti rapidi", + "Keyword:": "Parola chiave:", + "Lanczos": "Lanczos", + "Landscape": "Paesaggio", + "Language": "linguaggio", + "Last modified": "Ultima modifica", + "Layer": "Strato", + "Layer details": "Dettagli del livello", + "Layer is empty.": "Il livello è vuoto.", + "Layer is not compatible with resize": "Il livello non è compatibile con il ridimensionamento", + "Layer is vector, convert it to raster to apply this tool.": "Il livello è vettoriale, convertilo in raster per applicare questo strumento.", + "Layers": "Livelli", + "Layers:": "strati:", + "Layout:": "Disposizione:", + "Left": "Sinistra", + "Left to Right": "Da sinistra a destra", + "Level:": "Livello:", + "Levels:": "livelli:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Luminance:", + "Luminosity": "Luminosità", + "Magic Eraser Tool": "Strumento gomma magica", + "Merge Down": "Unisci giù", + "Merge Layers": "Unire i livelli", + "Merged": "Fusione", + "Metrics": "Metrica", + "Middle": "Medio", + "Missing at least 1 size parameter.": "Manca almeno 1 parametro di dimensione.", + "Missing permissions to write to Clipboard.cc": "Mancano i permessi per scrivere su Clipboard.cc", + "Mode:": "Modalità:", + "Module function not found.": "Funzione del modulo non trovata.", + "Modules class not found:": "Classe di moduli non trovata:", + "Monospace": "Monospace", + "Mosaic": "Mosaico", + "Mouse:": "Topo:", + "Move": "Mossa", + "Move Layer": "Sposta livello", + "Move layer down": "Sposta il livello verso il basso", + "Move layer up": "Sposta il livello verso l'alto", + "Name:": "Nome:", + "Negative": "Negativo", + "New": "Nuovo", + "New Bezier Layer": "Nuovo livello Bezier", + "New Brush Layer": "Nuovo livello pennello", + "New Ellipse Layer": "Nuovo livello ellisse", + "New File": "Nuovo file", + "New Gradient Layer": "Nuovo livello sfumato", + "New Layer": "Nuovo strato", + "New Line Layer": "Nuovo livello di linea", + "New Pencil Layer": "Nuovo livello matita", + "New Polygon Layer": "Nuovo livello poligono", + "New Rectangle Layer": "Nuovo livello rettangolo", + "New Text Layer": "Nuovo livello di testo", + "New file": "Nuovo file", + "New from Selection": "Novità dalla selezione", + "New layer": "Nuovo strato", + "Next": "Prossimo", + "Night Vision": "Visione notturna", + "None": "Nessuna", + "Nothing is selected.": "Niente è selezionato.", + "Offset X:": "Offset X:", + "Offset Y:": "Offset Y:", + "Oil": "Olio", + "Ok": "Ok", + "Online image editor.": "Editor di immagini online", + "Opacity": "Opacità", + "Opacity:": "Opacità:", + "Open": "Aperto", + "Open Data URL": "Apri URL dati", + "Open Directory": "Apri Directory", + "Open File": "Apri il file", + "Open File Data URL": "Apri URL dati file", + "Open File URL": "Apri URL file", + "Open File Webcam": "Apri File Webcam", + "Open Image": "Apri immagine", + "Open JSON File": "Apri file JSON", + "Open Test Template": "Apri modello di prova", + "Open URL": "Apri URL", + "Open data URL": "Apri l'URL dei dati", + "Open from Webcam": "Apri da webcam", + "Original Size": "Misura originale", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Converti immagine in SVG", + "PageDown": "Pagina giù", + "PageUp": "Pagina su", + "Palette": "Tavolozza", + "Parameter #1:": "Parametro n. 1:", + "Parameter #2:": "Parametro n. 2:", + "Paste": "Incolla", + "Pencil": "Matita", + "Percentage:": "Percentuale:", + "Pixels:": "pixel:", + "Placeholder comment for color channels": "Commento segnaposto per i canali di colore", + "Placeholder comment for color picker": "Commento segnaposto per il selettore di colori", + "Placeholder comment for color swatches": "Commento segnaposto per i campioni di colore", + "Portable Network Graphics": "Grafica di rete portatile", + "Portrait": "Ritratto", + "Português": "Português", + "Position:": "Posizione:", + "Power:": "Energia:", + "Preview": "Anteprima", + "Previous": "Precedente", + "Previous layer must be image, convert it to raster to apply this tool.": "Il livello precedente deve essere un'immagine, convertirlo in raster per applicare questo strumento.", + "Print": "Stampare", + "Quality:": "Qualità:", + "Quick Load": "Carico rapido", + "Quick Save": "Salvataggio veloce", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Rimuovi lo sfondo dell'immagine", + "Radial": "Radiale", + "Radial gradient": "Gradiente radiale", + "Radius:": "Raggio:", + "Range:": "Gamma:", + "Red": "Rosso", + "Red channel:": "Canale Rosso:", + "Redo": "Rifare", + "Remove all": "Rimuovi tutto", + "Rename": "Rinominare", + "Rename Layer": "Rinomina livello", + "Rendered with errors.": "Resi con errori.", + "Rendering...": "Rendering ...", + "Replace Color": "Sostituisci colore", + "Replace color": "Sostituisci colore", + "Replacement:": "Sostituzione:", + "Report Issues": "Segnala problemi", + "Reset": "Reset", + "Resize": "Ridimensiona", + "Resize Boundary": "Ridimensiona confine", + "Resize Layer": "Ridimensiona livello", + "Resize Layers": "Ridimensiona i livelli", + "Resize Text Layer": "Ridimensiona il livello del testo", + "Resized as background": "Ridimensionato come sfondo", + "Resized:": "Ridimensionato:", + "Resolution:": "Risoluzione:", + "Restore Alpha": "Ripristina alpha", + "Right": "Destra", + "Right angle:": "Angolo retto:", + "Right to Left": "Da destra a sinistra", + "Rotate": "Ruotare", + "Rotate Layer": "Ruota livello", + "Rotate is not supported on this type of object. Convert to raster?": "Ruota non è supportato su questo tipo di oggetto. Converti in raster?", + "Rotate left": "Gira a sinistra", + "Rotate:": "Ruotare:", + "Ruler": "Governate", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Comprimi e confronta le immagini", + "Saturate": "Saturare", + "Saturation": "Saturazione", + "Saturation:": "Saturazione:", + "Save As": "Salva come", + "Save As Data URL": "Salva come URL dei dati", + "Save as": "Salva come", + "Save as type:": "Salva come tipo:", + "Save layers:": "Salva livelli:", + "Scaling up is not supported in Hermite, using Lanczos.": "L'aumento di scala non è supportato in Hermite, utilizzando Lanczos.", + "Scroll down": "Scorri verso il basso", + "Scroll up": "Scorrere verso l'alto", + "Search": "Ricerca", + "Search Images": "Cerca immagini", + "Search for Font": "Cerca carattere", + "Search:": "Ricerca:", + "Select All": "Seleziona tutto", + "Select Text Layer": "Seleziona Livello testo", + "Select object tool": "Seleziona lo strumento oggetto", + "Selected": "Selezionato", + "Selection Tool": "Strumento di selezione", + "Sensitivity:": "sensibilità:", + "Separated": "Separato", + "Separated (original types)": "Separati (tipi originali)", + "Sepia": "nero di seppia", + "Set Image Size": "Imposta la dimensione dell'immagine", + "Settings": "impostazioni", + "Shadow": "Ombra", + "Shapes": "Forme", + "Shapes (H)": "Forme (H)", + "Sharpen": "Affilare", + "Sharpen Tool": "Strumento di nitidezza", + "Sharpen:": "Affilare:", + "Shift + S": "Maiusc+S", + "Shortcut Key:": "Tasto di scelta rapida:", + "Show": "Spettacolo", + "Show \/ Hide": "Mostra nascondi", + "Show file size:": "Mostra la dimensione del file:", + "Simple": "Semplice", + "Size is too big, max": "La dimensione è troppo grande, max", + "Size:": "Dimensione:", + "Skip - layer must be image.": "Salta: il livello deve essere un'immagine.", + "Solarize": "solarizzare", + "Sorry, cold not load getUserMedia() data:": "Spiacenti, non caricare i dati getUserMedia ():", + "Sorry, image could not be loaded.": "Spiacenti, impossibile caricare l'immagine.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Spiacenti, l'immagine non può essere caricata. Prova a copiare l'immagine e incollarla.", + "Sorry, image is too big, max 5 MB.": "Siamo spiacenti, l'immagine è troppo grande, max 5 MB.", + "Source coordinates saved.": "Coordinate di origine salvate.", + "Source is empty, right click on image or use long press to save source position.": "La sorgente è vuota, fare clic con il tasto destro sull'immagine o premere a lungo per salvare la posizione della sorgente.", + "Sprites": "sprites", + "Square": "Piazza", + "Stream:": "Stream:", + "Strength:": "Forza:", + "Strict": "Rigoroso", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Comprimi PNG e JPEG", + "Tab": "Tab", + "Tag Image File Format": "Etichetta il formato del file immagine", + "Tahoma": "Tahoma", + "Target:": "Bersaglio:", + "The quick brown fox jumps over the lazy dog.": "La veloce volpe marrone salta sopra il cane pigro.", + "There": "Là", + "There are no layers behind.": "Non ci sono strati dietro.", + "There is only 1 layer.": "C'è solo 1 strato.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Il livello deve essere un'immagine, convertirlo in raster per applicare questo strumento.", + "Tilt Shift": "Tilt Shift", + "Times New Roman": "Times New Roman", + "Toaster": "Tostapane", + "Toggle": "Toggle", + "Toggle Color Channels": "Attiva \/ disattiva i canali colore", + "Toggle Color Picker": "Attiva \/ disattiva il selettore dei colori", + "Toggle Menu": "Toggle Menu", + "Toggle Swatches": "Attiva \/ disattiva campioni", + "Tools": "Utensili", + "Top": "Superiore", + "Top to Bottom": "Dall'alto al basso", + "Total pixels:": "Pixel totali:", + "Translate": "Tradurre", + "Translate Layer": "Traduci Layer", + "Translate error, can not find dictionary:": "Traduci errore, impossibile trovare il dizionario:", + "Transparent:": "Trasparente:", + "Trim": "tagliare", + "Trim Layers": "Livelli di taglio", + "Trim borders:": "Taglia bordi:", + "Trim layer:": "Strato di rifinitura:", + "Trim white color?": "Tagliare il colore bianco?", + "Type:": "Tipo:", + "Türkçe": "Türkçe", + "Undo": "Disfare", + "Unique colors:": "Colori unici:", + "Up": "Su", + "Update": "Aggiornamento", + "Update Brush Layer": "Aggiorna livello pennello", + "Update Pencil Layer": "Aggiorna livello matita", + "Update guides": "Guide di aggiornamento", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Usa la scorciatoia da tastiera Ctrl + V per incollare dagli Appunti.", + "V Radius:": "V raggio:", + "V. Align:": "V. Allinea:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Versione:", + "Vertical": "Verticale", + "Vertical Alignment": "Allineamento verticale", + "Vertical blur:": "Sfocatura verticale:", + "Vertical:": "Verticale:", + "Vibrance": "Vibrance", + "View": "Visualizzazione", + "Vignette": "vignette", + "ViliusL": "ViliusL", + "Vintage": "Vintage ▾", + "Webcam": "Webcam", + "Webcam #": "Webcam #", + "Website:": "Sito web:", + "Weppy File Format": "Formato file Weppy", + "Width (%):": "Larghezza (%):", + "Width:": "Larghezza:", + "Windows Bitmap": "Bitmap di Windows", + "Word": "parola", + "Word + Letter": "Parola + Lettera", + "Wrap At:": "Avvolgi a:", + "Wrap:": "Avvolgere:", + "Wrong dimensions": "Dimensioni sbagliate", + "Wrong file type, must be image or json.": "Tipo di file errato, deve essere immagine o json.", + "X end:": "X fine:", + "X position:": "Posizione X:", + "X start:": "X inizio:", + "X-Pro II": "X-Pro II", + "Y end:": "Fine Y:", + "Y position:": "Posizione Y:", + "Y start:": "Y inizio:", + "You can also drag and drop items into browser.": "Puoi anche trascinare gli oggetti nel browser.", + "Your browser does not support canvas or JavaScript is not enabled.": "Il tuo browser non supporta canvas o JavaScript non è abilitato.", + "Your browser does not support this format.": "Il tuo browser non supporta questo formato.", + "Your search did not match any images.": "La tua ricerca non corrisponde ad alcuna immagine.", + "Zoom": "Zoom", + "Zoom Blur": "Zoom sfocatura", + "Zoom In": "Ingrandire", + "Zoom Out": "Zoom indietro", + "Zoom blur": "Sfocatura dello zoom", + "Zoom in": "Ingrandire", + "Zoom out": "Zoom indietro", + "Zoom:": "Zoom:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/ja.json b/paintplus/frontend/src/js/languages/ja.json new file mode 100644 index 0000000..76be0e5 --- /dev/null +++ b/paintplus/frontend/src/js/languages/ja.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "元に戻す履歴の削除中に問題が発生しました。それ", + "About": "開発者について", + "Active": "アクティブ", + "Aden": "アデン", + "Advanced": "上級", + "All": "すべて", + "Alpha": "アルファ", + "Alpha:": "アルファ:", + "Anonymous": "匿名", + "Anti aliasing": "アンチエイリアシング", + "Application markup may have changed,": "アプリケーションのマークアップが変更されている可能性があります。", + "Arial": "Arial", + "Arrow": "矢印", + "ArrowDown": "ArrowDown", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + "ArrowUp": "ArrowUp", + "Author:": "著者:", + "Auto Adjust Colors": "色を自動調整する", + "Auto Kerning": "自動カーニング", + "Average:": "平均:", + "Backspace": "バックスペース", + "Base": "ベース", + "Basic": "ベーシック", + "Black and White": "黒と白", + "Blue": "青", + "Blue channel:": "ブルーチャンネル:", + "Blueprint": "青写真", + "Blur Radius:": "ぼかし半径:", + "Blur Tool": "ぼかしツール", + "Blur power:": "ぼかしパワー:", + "Borders": "罫線", + "Bottom": "下", + "Bottom to Top": "下から上へ", + "Bounds:": "境界:", + "Box": "ボックス", + "Box Blur": "ボックスのぼかし", + "Box blur": "ボックスボケ", + "Brightness": "輝度", + "Brightness:": "輝度:", + "Bulge\/Pinch Tool": "バルジ\/ピンチツール", + "Burn": "燃やす", + "Can not animate 1 layer.": "1つのレイヤーをアニメートできません。", + "Can not find previous layer.": "以前のレイヤーが見つかりません。", + "Can not use this tool on current layer: image already takes all area.": "現在のレイヤーではこのツールを使用できません: 画像がすでにすべての領域を占めています。", + "Cancel": "キャンセル", + "Canvas Size": "キャンバスサイズ", + "Center": "センター", + "Center x:": "センターx:", + "Center y:": "センターy:", + "Center:": "センター:", + "Change Composition": "構成を変更する", + "Change Layer Details": "レイヤーの詳細を変更する", + "Change Opacity": "不透明度を変更する", + "Channel:": "チャネル:", + "Circle": "サークル", + "Clarendon": "クラレンドン", + "Clear": "クリア", + "Clear Selection": "明確な選択", + "Clone Tool": "クローンツール", + "Clone count:": "クローン数:", + "Clone tool disabled for resized image. Please rasterize first.": "サイズ変更された画像に対してクローン ツールが無効になりました。まずはラスタライズを行ってください。", + "Cloned edges": "クローンエッジ", + "Close": "近い", + "Color #": "色 #", + "Color Corrections": "色補正", + "Color Palette": "カラーパレット", + "Color Zoom": "カラーズーム", + "Color alpha value can not be zero.": "色のアルファ値はゼロにすることはできません。", + "Color to Alpha": "カラーからアルファ", + "Color zoom": "カラーズーム", + "Color:": "色:", + "Colors": "色", + "Colors:": "色:", + "Common Filters": "共通フィルター", + "Composition": "レイヤーの合成", + "Composition:": "レイヤーの合成:", + "Content Fill": "コンテンツの埋め込み", + "Contrast": "コントラスト", + "Contrast:": "コントラスト:", + "Convert layer to raster": "レイヤーをラスターに変換", + "Convert to Raster": "ラスタに変換する", + "Copy Selection": "選択コピー", + "Copy to Clipboard": "クリップボードにコピー", + "Courier": "宅配便", + "Crop Tool": "切り抜きツール", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "回転したレイヤーでのトリミングはサポートされていません。続行するには、ラスターに変換してください。", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "現在", + "Current Color Preview": "現在のカラープレビュー", + "Custom": "カスタム", + "Data URL": "データURL", + "Data URL:": "データURL:", + "Decrease": "減少", + "Decrease Color Depth": "色深度を減らす", + "Degree:": "度:", + "Del": "デル", + "Delete": "削除", + "Delete Selection": "選択を削除する", + "Denoise": "デノアーズ", + "Desaturate Tool": "彩度を下げるツール", + "Description:": "説明:", + "Deutsch": "ドイツ語", + "Differences": "相違点", + "Differences Down": "相違点", + "Direction:": "方向:", + "Dither": "ディザ", + "Dithering:": "ディザリング:", + "Dominant color:": "支配的な色:", + "Dot Screen": "ドットスクリーン", + "Down": "ダウン", + "Duplicate": "重複", + "Duplicate Layer": "重複レイヤー", + "Duplicate layer": "レイヤーの複製", + "Dynamic": "動的", + "Edge": "エッジ", + "Edit": "編集", + "Edit text...": "テキストを編集...", + "Effect browser": "エフェクトブラウザ", + "Effects": "エフェクト", + "Effects browser": "エフェクトブラウザ", + "Email:": "Eメール:", + "Emboss": "エンボス", + "Empty selection": "空の選択", + "Empty selection or type not image.": "空の選択またはタイプではない画像。", + "Enable autoresize:": "自動サイズ変更を有効にする:", + "End": "終わり", + "English": "英語", + "English (UK)": "英語(イギリス)", + "Enrich": "エンリッチ", + "Enter": "入る", + "Erase Tool": "消去ツール", + "Erase on rotate object is disabled. Please rasterize first.": "オブジェクトの回転時の消去は無効になっています。まずはラスタライズを行ってください。", + "Error": "エラー", + "Error connecting to service.": "サービスに接続中にエラーが発生しました。", + "Error loading the list of fonts from Google.": "Google からフォントのリストをロード中にエラーが発生しました。", + "Error registering service worker": "Service Worker の登録エラー", + "Error: can not find filter:": "エラー:フィルターが見つかりません:", + "Error: can not find layer with id:": "エラー:IDのレイヤーが見つかりません:", + "Error: missing details event target": "エラー:詳細イベントターゲットがありません", + "Error: unknown layer type:": "エラー:不明なレイヤータイプ:", + "Error: unsupported attribute type:": "エラー: サポートされていない属性タイプ:", + "Esc": "ESC", + "Escape": "逃れる", + "Español": "スペイン語", + "Expand edges": "エッジを開く", + "Exponent:": "指数:", + "Export": "エクスポート", + "External": "外部サイト", + "Factor:": "因子:", + "File": "ファイル", + "File name:": "ファイル名:", + "File size:": "ファイルサイズ:", + "Fill": "塗りつぶす", + "Fill Tool": "塗りつぶしツール", + "Fit": "フィット", + "Fit Window": "ウィンドウに合わせる", + "Fit window": "ウィンドウにフィット", + "Flatten Image": "画像を平ら", + "Flip": "反転", + "FloydSteinberg-serpentine": "FloydSteinberg-蛇紋文字", + "Font": "フォント", + "Français": "フランス語", + "Full HD, 1080p": "フルHD、1080p", + "Full Screen": "全画面表示", + "Full layers data": "フルレイヤーデータ", + "Gap:": "ギャップ:", + "Gaussian Blur": "ガウスぼかし", + "Gif delay:": "GIF遅延:", + "Gingham": "ギンガム", + "GitHub:": "GitHub:", + "Gradient Radius:": "勾配半径:", + "Grains": "フィルムグレイン", + "Graphics Interchange Format": "グラフィック交換フォーマット", + "Gray": "グレー", + "Grayscale": "グレースケール", + "Greek": "ギリシャ語", + "Green": "緑", + "Green channel:": "グリーンチャネル:", + "Greyscale:": "グレースケール:", + "Grid": "グリッド", + "Grid on\/off": "グリッドのオン\/オフ", + "Guides": "ガイド", + "Guides enabled.": "ガイドが有効になりました。", + "H Radius:": "H半径:", + "H. Align:": "H.整列:", + "Heatmap": "ヒートマップ", + "Height (%):": "高さ (%):", + "Height:": "高さ:", + "Help": "Help", + "Helvetica": "ヘルベチカ", + "Hermite": "エルミート", + "Hex": "16進数", + "Hide": "隠れる", + "Histogram": "ヒストグラム", + "Histogram:": "ヒストグラム:", + "Home": "ホーム", + "Horizontal": "水平", + "Horizontal Alignment": "水平方向の配置", + "Horizontal blur:": "水平ブラー:", + "Horizontal:": "水平:", + "Hue": "色相", + "Hue Rotate": "色相回転", + "Hue:": "色相:", + "Image": "画像", + "Image data with multi-layers. Can be opened using miniPaint -": "マルチレイヤーの画像データ。 miniPaintを使用して開くことができます -", + "Impact": "影響", + "In proportion:": "比例して:", + "Increase": "増加する", + "Information": "情報", + "Inkwell": "インク壺", + "Insert": "追加", + "Insert guides": "インサートガイド", + "Insert new layer": "新しいレイヤーを挿入", + "Instagram Filters": "Instagramフィルター", + "Invalid Hex Code": "無効な16進コード", + "Italiano": "イタリア語", + "JPG\/JPEG Format": "JPG \/ JPEG形式", + "Kerning:": "字詰め:", + "Key-Points": "キーポイント", + "KeyU": "キーユー", + "Keyboard Shortcuts": "キーボードショートカット", + "Keyword:": "キーワード:", + "Lanczos": "ランチョス", + "Landscape": "風景", + "Language": "言語", + "Last modified": "最終更新日", + "Layer": "レイヤー", + "Layer details": "レイヤの詳細", + "Layer is empty.": "レイヤーが空です。", + "Layer is not compatible with resize": "レイヤーはサイズ変更と互換性がありません", + "Layer is vector, convert it to raster to apply this tool.": "レイヤーはベクターです。このツールを適用するには、レイヤーをラスターに変換してください。", + "Layers": "レイヤー", + "Layers:": "レイヤー:", + "Layout:": "レイアウト:", + "Left": "左", + "Left to Right": "左から右へ", + "Level:": "レベル:", + "Levels:": "レベル:", + "Lietuvių": "Lietuvių", + "Lo-fi": "ローファイ", + "Luminance:": "輝度:", + "Luminosity": "光度", + "Magic Eraser Tool": "魔法の消しゴムツール", + "Merge Down": "マージダウン", + "Merge Layers": "レイヤーをマージする", + "Merged": "合併", + "Metrics": "指標", + "Middle": "中間", + "Missing at least 1 size parameter.": "少なくとも1つのサイズパラメータがありません。", + "Missing permissions to write to Clipboard.cc": "Clipboard.ccに書き込むためのアクセス許可がありません", + "Mode:": "モード:", + "Module function not found.": "モジュール機能が見つかりません。", + "Modules class not found:": "モジュールクラスが見つかりません:", + "Monospace": "モノスペース", + "Mosaic": "モザイク", + "Mouse:": "マウス:", + "Move": "移動", + "Move Layer": "レイヤーを移動", + "Move layer down": "レイヤーを下に移動します", + "Move layer up": "レイヤーを上に移動", + "Name:": "名:", + "Negative": "負", + "New": "新しい", + "New Bezier Layer": "新しいベジェ層", + "New Brush Layer": "新しいブラシレイヤー", + "New Ellipse Layer": "新しい楕円レイヤー", + "New File": "新しいファイル", + "New Gradient Layer": "新しいグラデーションレイヤー", + "New Layer": "新しいレイヤー", + "New Line Layer": "新しいラインレイヤー", + "New Pencil Layer": "新しい鉛筆レイヤー", + "New Polygon Layer": "新しいポリゴンレイヤー", + "New Rectangle Layer": "新しい長方形レイヤー", + "New Text Layer": "新しいテキストレイヤー", + "New file": "新しいファイル", + "New from Selection": "新しい選択から", + "New layer": "新しいレイヤー", + "Next": "次", + "Night Vision": "暗視ゴーグル 緑", + "None": "なし", + "Nothing is selected.": "何も選択されていません。", + "Offset X:": "オフセットX:", + "Offset Y:": "オフセットY:", + "Oil": "油", + "Ok": "OK", + "Online image editor.": "オンラインイメージエディタ。", + "Opacity": "不透明度", + "Opacity:": "不透明度:", + "Open": "開く", + "Open Data URL": "公開データURL", + "Open Directory": "ディレクトリを開く", + "Open File": "ファイルを開く", + "Open File Data URL": "ファイルデータのURLを開く", + "Open File URL": "ファイルのURLを開く", + "Open File Webcam": "ファイルWebカメラを開く", + "Open Image": "画像を開く", + "Open JSON File": "JSONファイルを開く", + "Open Test Template": "テストテンプレートを開く", + "Open URL": "URLを開く", + "Open data URL": "公開データURL", + "Open from Webcam": "ウェブカメラから開く", + "Original Size": "オリジナルサイズ", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - 画像をSVGに変換", + "PageDown": "ページダウン", + "PageUp": "ページアップ", + "Palette": "パレット", + "Parameter #1:": "パラメータ#1:", + "Parameter #2:": "パラメータ#2:", + "Paste": "ペースト", + "Pencil": "鉛筆", + "Percentage:": "パーセンテージ:", + "Pixels:": "ピクセル:", + "Placeholder comment for color channels": "カラーチャンネルのプレースホルダーコメント", + "Placeholder comment for color picker": "カラーピッカーのプレースホルダーコメント", + "Placeholder comment for color swatches": "色見本のプレースホルダーコメント", + "Portable Network Graphics": "ポータブルネットワークグラフィックス", + "Portrait": "肖像画", + "Português": "ポルトガル語", + "Position:": "位置:", + "Power:": "力:", + "Preview": "プレビュー", + "Previous": "前", + "Previous layer must be image, convert it to raster to apply this tool.": "前のレイヤーはイメージでなければならず、このツールを適用するにはラスターに変換する必要があります。", + "Print": "印刷", + "Quality:": "品質:", + "Quick Load": "クイックロード", + "Quick Save": "クイックセーブ", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - 画像の背景を削除する", + "Radial": "ラジアル", + "Radial gradient": "放射グラジエント", + "Radius:": "半径:", + "Range:": "範囲:", + "Red": "赤", + "Red channel:": "赤いチャンネル:", + "Redo": "やり直し", + "Remove all": "すべて削除する", + "Rename": "名前を変更する", + "Rename Layer": "レイヤーの名前を変更", + "Rendered with errors.": "エラーでレンダリングされました。", + "Rendering...": "レンダリング...", + "Replace Color": "色を置き換える", + "Replace color": "色を交換する", + "Replacement:": "置換:", + "Report Issues": "レポートの問題", + "Reset": "リセット", + "Resize": "サイズを変更する", + "Resize Boundary": "境界のサイズ変更", + "Resize Layer": "レイヤーのサイズ変更", + "Resize Layers": "レイヤーのサイズ変更", + "Resize Text Layer": "テキストレイヤーのサイズ変更", + "Resized as background": "背景としてサイズ変更", + "Resized:": "サイズ変更:", + "Resolution:": "解像度(ppi):", + "Restore Alpha": "アルファを復元する", + "Right": "右", + "Right angle:": "直角:", + "Right to Left": "右から左へ", + "Rotate": "回転する", + "Rotate Layer": "レイヤーを回転", + "Rotate is not supported on this type of object. Convert to raster?": "このタイプのオブジェクトでは、回転はサポートされていません。ラスタに変換しますか?", + "Rotate left": "左に回転", + "Rotate:": "回転:", + "Ruler": "ルーラー", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - 画像を圧縮して比較する", + "Saturate": "飽和", + "Saturation": "飽和", + "Saturation:": "飽和:", + "Save As": "名前を付けて保存", + "Save As Data URL": "データURLとして保存", + "Save as": "名前を付けて保存", + "Save as type:": "タイプとして保存:", + "Save layers:": "レイヤーを保存:", + "Scaling up is not supported in Hermite, using Lanczos.": "ランチョスを使用したエルミートでは、スケールアップはサポートされていません。", + "Scroll down": "下へスクロール", + "Scroll up": "スクロールアップする", + "Search": "サーチ", + "Search Images": "画像を検索する", + "Search for Font": "フォントの検索", + "Search:": "検索:", + "Select All": "すべて選択", + "Select Text Layer": "テキストレイヤーを選択", + "Select object tool": "オブジェクトツールを選択", + "Selected": "選択された", + "Selection Tool": "選択ツール", + "Sensitivity:": "感度:", + "Separated": "分離", + "Separated (original types)": "セパレート(オリジナルタイプ)", + "Sepia": "セピア", + "Set Image Size": "画像サイズを設定する", + "Settings": "設定", + "Shadow": "影", + "Shapes": "形", + "Shapes (H)": "形状(H)", + "Sharpen": "シャープ", + "Sharpen Tool": "シャープツール", + "Sharpen:": "シャープ:", + "Shift + S": "Shift + S", + "Shortcut Key:": "ショートカットキー:", + "Show": "見せる", + "Show \/ Hide": "表示\/非表示", + "Show file size:": "ファイルサイズを表示:", + "Simple": "シンプル", + "Size is too big, max": "サイズが大きすぎます", + "Size:": "サイズ:", + "Skip - layer must be image.": "スキップ - レイヤはイメージでなければなりません。", + "Solarize": "ソラリゼーション", + "Sorry, cold not load getUserMedia() data:": "申し訳ありませんが、getUserMedia()データをロードしないでください:", + "Sorry, image could not be loaded.": "申し訳ありませんが、画像を読み込めませんでした。", + "Sorry, image could not be loaded. Try copy image and paste it.": "申し訳ありませんが、画像を読み込めませんでした。イメージをコピーして貼り付けてみてください。", + "Sorry, image is too big, max 5 MB.": "申し訳ありませんが、イメージが大きすぎます(最大5 MB)。", + "Source coordinates saved.": "保存されたソース座標。", + "Source is empty, right click on image or use long press to save source position.": "ソースが空です。画像を右クリックするか、長押ししてソースの位置を保存します。", + "Sprites": "スプライト", + "Square": "平方", + "Stream:": "ストリーム:", + "Strength:": "力:", + "Strict": "厳格", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - PNGとJPEGを圧縮します", + "Tab": "タブ", + "Tag Image File Format": "タグ画像ファイル形式", + "Tahoma": "タホマ", + "Target:": "ターゲット:", + "The quick brown fox jumps over the lazy dog.": "素早い茶色のキツネが怠惰な犬を飛び越えます。", + "There": "そこ", + "There are no layers behind.": "後ろに層がありません。", + "There is only 1 layer.": "レイヤーは1つしかありません。", + "This layer must contain an image. Please convert it to raster to apply this tool.": "レイヤはイメージでなければならず、このツールを適用するにはラスタに変換する必要があります。", + "Tilt Shift": "チルトシフト", + "Times New Roman": "Times New Roman", + "Toaster": "トースター", + "Toggle": "トグル", + "Toggle Color Channels": "カラーチャンネルを切り替えます", + "Toggle Color Picker": "トグルカラーピッカー", + "Toggle Menu": "トグルメニュー", + "Toggle Swatches": "トグルスウォッチ", + "Tools": "ツール", + "Top": "上", + "Top to Bottom": "上から下へ", + "Total pixels:": "合計ピクセル数:", + "Translate": "画像を移動", + "Translate Layer": "翻訳レイヤー", + "Translate error, can not find dictionary:": "翻訳エラー、辞書が見つかりません:", + "Transparent:": "トランスペアレント:", + "Trim": "トリム", + "Trim Layers": "レイヤーのトリム", + "Trim borders:": "境界線をトリミングします。", + "Trim layer:": "トリムレイヤー:", + "Trim white color?": "白い色をトリム?", + "Type:": "タイプ:", + "Türkçe": "Türkçe", + "Undo": "元に戻す", + "Unique colors:": "ユニークな色:", + "Up": "アップ", + "Update": "アップデート", + "Update Brush Layer": "ブラシレイヤーを更新する", + "Update Pencil Layer": "鉛筆レイヤーを更新する", + "Update guides": "アップデートガイド", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Ctrl + Vキーボードショートカットを使用してクリップボードから貼り付けます。", + "V Radius:": "V半径:", + "V. Align:": "V.整列:", + "Valencia": "バレンシア", + "Verdana": "ヴェルダナ", + "Version:": "バージョン:", + "Vertical": "垂直", + "Vertical Alignment": "垂直方向の配置", + "Vertical blur:": "垂直方向のぼかし:", + "Vertical:": "垂直:", + "Vibrance": "バイブランス", + "View": "ビュー", + "Vignette": "ビネット", + "ViliusL": "ViliusL", + "Vintage": "ビンテージ", + "Webcam": "ウェブカメラ", + "Webcam #": "ウェブカメラ #", + "Website:": "ウェブサイト:", + "Weppy File Format": "Weppyファイル形式", + "Width (%):": "幅(%):", + "Width:": "幅:", + "Windows Bitmap": "Windowsビットマップ", + "Word": "語", + "Word + Letter": "単語+文字", + "Wrap At:": "ラップ場所:", + "Wrap:": "ラップ:", + "Wrong dimensions": "間違った寸法", + "Wrong file type, must be image or json.": "間違ったファイルタイプです。画像またはjsonでなければなりません。", + "X end:": "X end:", + "X position:": "X位置:", + "X start:": "Xスタート:", + "X-Pro II": "X-Pro II", + "Y end:": "Y end:", + "Y position:": "Y位置:", + "Y start:": "Y開始:", + "You can also drag and drop items into browser.": "アイテムをブラウザにドラッグアンドドロップすることもできます。", + "Your browser does not support canvas or JavaScript is not enabled.": "ブラウザがキャンバスをサポートしていないか、JavaScriptが有効になっていません。", + "Your browser does not support this format.": "お使いのブラウザはこの形式をサポートしていません。", + "Your search did not match any images.": "あなたの検索はどの画像にも一致しませんでした。", + "Zoom": "ズーム", + "Zoom Blur": "ズームぼかし", + "Zoom In": "ズームイン", + "Zoom Out": "ズームアウトする", + "Zoom blur": "ズームブラー", + "Zoom in": "ズームイン", + "Zoom out": "ズームアウトする", + "Zoom:": "ズーム:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/ko.json b/paintplus/frontend/src/js/languages/ko.json new file mode 100644 index 0000000..194f365 --- /dev/null +++ b/paintplus/frontend/src/js/languages/ko.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "실행 취소 기록을 제거하는 동안 문제가 발생했습니다. 그것", + "About": "약", + "Active": "유효한", + "Aden": "아덴", + "Advanced": "많은", + "All": "모든", + "Alpha": "알파", + "Alpha:": "알파 :", + "Anonymous": "익명", + "Anti aliasing": "안티 앨리어싱", + "Application markup may have changed,": "애플리케이션 마크업이 변경되었을 수 있습니다.", + "Arial": "Arial", + "Arrow": "화살", + "ArrowDown": "ArrowDown", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + "ArrowUp": "ArrowUp", + "Author:": "저자:", + "Auto Adjust Colors": "색상 자동 조정", + "Auto Kerning": "자동 커닝", + "Average:": "평균:", + "Backspace": "역행 키이", + "Base": "베이스", + "Basic": "기본", + "Black and White": "검정색과 흰색", + "Blue": "푸른", + "Blue channel:": "파란색 채널 :", + "Blueprint": "청사진", + "Blur Radius:": "흐리게 반경 :", + "Blur Tool": "블러 도구", + "Blur power:": "흐림 효과 :", + "Borders": "테두리", + "Bottom": "바닥", + "Bottom to Top": "아래에서 위로", + "Bounds:": "범위:", + "Box": "상자", + "Box Blur": "상자 흐림 효과", + "Box blur": "상자 흐림 효과", + "Brightness": "명도", + "Brightness:": "명도:", + "Bulge\/Pinch Tool": "벌지 \/ 핀치 도구", + "Burn": "화상", + "Can not animate 1 layer.": "1 개의 레이어를 애니메이션으로 만들 수 없습니다.", + "Can not find previous layer.": "이전 레이어를 찾을 수 없습니다.", + "Can not use this tool on current layer: image already takes all area.": "현재 레이어에서는 이 도구를 사용할 수 없습니다. 이미지가 이미 모든 영역을 차지하고 있습니다.", + "Cancel": "취소", + "Canvas Size": "캔버스 크기", + "Center": "센터", + "Center x:": "센터 x :", + "Center y:": "센터 y :", + "Center:": "센터:", + "Change Composition": "구성 변경", + "Change Layer Details": "레이어 세부 정보 변경", + "Change Opacity": "불투명도 변경", + "Channel:": "채널:", + "Circle": "원", + "Clarendon": "Clarendon", + "Clear": "명확한", + "Clear Selection": "명확한 선택", + "Clone Tool": "복제 도구", + "Clone count:": "클론 횟수 :", + "Clone tool disabled for resized image. Please rasterize first.": "크기가 조정된 이미지에 대해 복제 도구가 비활성화되었습니다. 먼저 래스터화해 주세요.", + "Cloned edges": "복제 된 가장자리", + "Close": "닫다", + "Color #": "색깔 #", + "Color Corrections": "색상 보정", + "Color Palette": "색상 팔레트", + "Color Zoom": "색상 확대 \/ 축소", + "Color alpha value can not be zero.": "색상 알파 값은 0 일 수 없습니다.", + "Color to Alpha": "알파에서 색상으로", + "Color zoom": "색상 확대 \/ 축소", + "Color:": "색깔:", + "Colors": "그림 물감", + "Colors:": "그림 물감:", + "Common Filters": "공통 필터", + "Composition": "구성", + "Composition:": "구성:", + "Content Fill": "콘텐츠 채우기", + "Contrast": "대조", + "Contrast:": "대조:", + "Convert layer to raster": "레이어를 래스터로 변환", + "Convert to Raster": "래스터로 변환", + "Copy Selection": "선택 항목 복사", + "Copy to Clipboard": "클립 보드에 복사", + "Courier": "급사", + "Crop Tool": "자르기 도구", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "회전 된 레이어에서 자르기는 지원되지 않습니다. 계속하려면 래스터로 변환하십시오.", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "흐름", + "Current Color Preview": "현재 색상 미리보기", + "Custom": "관습", + "Data URL": "데이터 URL", + "Data URL:": "데이터 URL :", + "Decrease": "감소", + "Decrease Color Depth": "색상 심도 감소", + "Degree:": "정도:", + "Del": "델", + "Delete": "지우다", + "Delete Selection": "선택 항목 삭제", + "Denoise": "데니스 이스", + "Desaturate Tool": "채도 제거 도구", + "Description:": "기술:", + "Deutsch": "Deutsch", + "Differences": "차이점", + "Differences Down": "차이점", + "Direction:": "방향:", + "Dither": "떨림", + "Dithering:": "디더링 :", + "Dominant color:": "주된 색깔 :", + "Dot Screen": "도트 스크린", + "Down": "하위", + "Duplicate": "복제", + "Duplicate Layer": "중복 레이어", + "Duplicate layer": "레이어 복제", + "Dynamic": "동적", + "Edge": "가장자리", + "Edit": "편집하다", + "Edit text...": "텍스트 수정 ...", + "Effect browser": "효과 브라우저", + "Effects": "효과", + "Effects browser": "효과 브라우저", + "Email:": "이메일:", + "Emboss": "엠보싱", + "Empty selection": "빈 선택", + "Empty selection or type not image.": "이미지를 선택하지 않거나 입력하지 마십시오.", + "Enable autoresize:": "자동 크기 조정 활성화:", + "End": "종료", + "English": "영어", + "English (UK)": "영어(영국)", + "Enrich": "높이다", + "Enter": "시작하다", + "Erase Tool": "지우기 도구", + "Erase on rotate object is disabled. Please rasterize first.": "개체 회전 시 지우기가 비활성화됩니다. 먼저 래스터화해 주세요.", + "Error": "오류", + "Error connecting to service.": "서비스에 연결하는 중 오류가 발생했습니다.", + "Error loading the list of fonts from Google.": "Google에서 글꼴 목록을 로드하는 중에 오류가 발생했습니다.", + "Error registering service worker": "서비스 워커 등록 오류", + "Error: can not find filter:": "오류 : 필터를 찾을 수 없음 :", + "Error: can not find layer with id:": "오류 : ID가있는 레이어를 찾을 수 없습니다.", + "Error: missing details event target": "오류 : 세부 정보 이벤트 대상이 누락되었습니다.", + "Error: unknown layer type:": "오류 : 알 수없는 레이어 유형 :", + "Error: unsupported attribute type:": "오류: 지원되지 않는 속성 유형:", + "Esc": "Esc", + "Escape": "탈출", + "Español": "스페인어", + "Expand edges": "가장자리 확장", + "Exponent:": "멱지수:", + "Export": "내보내다", + "External": "외부", + "Factor:": "인자:", + "File": "파일", + "File name:": "파일 이름:", + "File size:": "파일 크기 :", + "Fill": "가득 따르다", + "Fill Tool": "채우기 도구", + "Fit": "적당한", + "Fit Window": "창에 맞추기", + "Fit window": "창 맞추기", + "Flatten Image": "납작한 이미지", + "Flip": "튀기다", + "FloydSteinberg-serpentine": "FloydSteinberg- 사문석", + "Font": "폰트", + "Français": "Français", + "Full HD, 1080p": "풀 HD, 1080p", + "Full Screen": "전체 화면", + "Full layers data": "전체 레이어 데이터", + "Gap:": "갭:", + "Gaussian Blur": "가우스 흐림", + "Gif delay:": "GIF 지연 :", + "Gingham": "깅엄", + "GitHub:": "GitHub :", + "Gradient Radius:": "기울기 반경 :", + "Grains": "작살", + "Graphics Interchange Format": "그래픽 교환 형식", + "Gray": "회색", + "Grayscale": "그레이 스케일", + "Greek": "그리스 어", + "Green": "녹색", + "Green channel:": "녹색 통로:", + "Greyscale:": "그레이 스케일 :", + "Grid": "그리드", + "Grid on\/off": "그리드 켜기 \/ 끄기", + "Guides": "가이드", + "Guides enabled.": "가이드가 활성화되었습니다.", + "H Radius:": "H 반경 :", + "H. Align:": "H. 정렬 :", + "Heatmap": "히트 맵", + "Height (%):": "높이 (%) :", + "Height:": "신장:", + "Help": "도움", + "Helvetica": "헬 베티 카", + "Hermite": "허 마이트", + "Hex": "마녀", + "Hide": "숨다", + "Histogram": "히스토그램", + "Histogram:": "히스토그램 :", + "Home": "집", + "Horizontal": "수평", + "Horizontal Alignment": "수평 정렬", + "Horizontal blur:": "가로 흐리게 :", + "Horizontal:": "수평의:", + "Hue": "색조", + "Hue Rotate": "색조 회전", + "Hue:": "색조:", + "Image": "영상", + "Image data with multi-layers. Can be opened using miniPaint -": "다중 레이어가있는 이미지 데이터. miniPaint를 사용하여 열 수 있습니다 -", + "Impact": "충격", + "In proportion:": "비례:", + "Increase": "증가하다", + "Information": "정보", + "Inkwell": "잉크 그릇", + "Insert": "끼워 넣다", + "Insert guides": "가이드 삽입", + "Insert new layer": "새 레이어 삽입", + "Instagram Filters": "Instagram 필터", + "Invalid Hex Code": "잘못된 16 진수 코드", + "Italiano": "이탈리아어", + "JPG\/JPEG Format": "JPG \/ JPEG 형식", + "Kerning:": "커닝 :", + "Key-Points": "키 포인트", + "KeyU": "키유", + "Keyboard Shortcuts": "키보드 단축키", + "Keyword:": "예어:", + "Lanczos": "Lanczos", + "Landscape": "풍경", + "Language": "언어", + "Last modified": "최종 수정일", + "Layer": "층", + "Layer details": "레이어 세부 정보", + "Layer is empty.": "레이어가 비어 있습니다.", + "Layer is not compatible with resize": "레이어는 크기 조정과 호환되지 않습니다.", + "Layer is vector, convert it to raster to apply this tool.": "레이어는 벡터이므로 래스터로 변환하여이 도구를 적용합니다.", + "Layers": "레이어", + "Layers:": "레이어 :", + "Layout:": "공들여 나열한 것:", + "Left": "왼쪽", + "Left to Right": "좌에서 우로", + "Level:": "수평:", + "Levels:": "레벨 :", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-Fi", + "Luminance:": "휘도 :", + "Luminosity": "밝기", + "Magic Eraser Tool": "매직 지우개 도구", + "Merge Down": "병합", + "Merge Layers": "계층을 병합하다", + "Merged": "병합 됨", + "Metrics": "지표", + "Middle": "가운데", + "Missing at least 1 size parameter.": "크기 매개 변수가 1 개 이상 누락되었습니다.", + "Missing permissions to write to Clipboard.cc": "Clipboard.cc에 쓸 수있는 권한이 없습니다.", + "Mode:": "방법:", + "Module function not found.": "모듈 기능을 찾을 수 없습니다.", + "Modules class not found:": "모듈 클래스를 찾을 수 없음 :", + "Monospace": "고정 폭", + "Mosaic": "모자이크", + "Mouse:": "쥐:", + "Move": "움직임", + "Move Layer": "레이어 이동", + "Move layer down": "레이어를 아래로 이동", + "Move layer up": "레이어를 위로 이동", + "Name:": "이름:", + "Negative": "부정", + "New": "새로운", + "New Bezier Layer": "새로운 베지어 레이어", + "New Brush Layer": "새 브러시 레이어", + "New Ellipse Layer": "새 타원 레이어", + "New File": "새로운 파일", + "New Gradient Layer": "새로운 그라디언트 레이어", + "New Layer": "새 레이어", + "New Line Layer": "새 라인 레이어", + "New Pencil Layer": "새 연필 레이어", + "New Polygon Layer": "새로운 폴리곤 레이어", + "New Rectangle Layer": "새로운 직사각형 레이어", + "New Text Layer": "새 텍스트 레이어", + "New file": "새로운 파일", + "New from Selection": "선택 항목의 새로운 기능", + "New layer": "새 레이어", + "Next": "다음", + "Night Vision": "나이트 비전", + "None": "없음", + "Nothing is selected.": "아무것도 선택되지 않았습니다.", + "Offset X:": "오프셋 X :", + "Offset Y:": "오프셋 Y :", + "Oil": "기름", + "Ok": "승인", + "Online image editor.": "온라인 이미지 편집기.", + "Opacity": "불투명", + "Opacity:": "불투명:", + "Open": "열다", + "Open Data URL": "공개 데이터 URL", + "Open Directory": "오픈 디렉토리", + "Open File": "파일 열기", + "Open File Data URL": "파일 데이터 URL 열기", + "Open File URL": "파일 URL 열기", + "Open File Webcam": "파일 열기 웹캠", + "Open Image": "이미지 열기", + "Open JSON File": "JSON 파일 열기", + "Open Test Template": "테스트 템플릿 열기", + "Open URL": "URL 열기", + "Open data URL": "공개 데이터 URL", + "Open from Webcam": "웹캠에서 열기", + "Original Size": "원본 크기", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG-이미지를 SVG로 변환", + "PageDown": "PageDown", + "PageUp": "페이지 위로", + "Palette": "팔레트", + "Parameter #1:": "매개 변수 # 1 :", + "Parameter #2:": "매개 변수 # 2 :", + "Paste": "풀", + "Pencil": "연필", + "Percentage:": "백분율:", + "Pixels:": "픽셀 :", + "Placeholder comment for color channels": "색상 채널에 대한 자리 표시 자 주석", + "Placeholder comment for color picker": "색상 선택기에 대한 자리 표시 자 주석", + "Placeholder comment for color swatches": "색상 견본에 대한 자리 표시 자 주석", + "Portable Network Graphics": "휴대용 네트워크 그래픽", + "Portrait": "초상화", + "Português": "Português", + "Position:": "위치:", + "Power:": "힘:", + "Preview": "시사", + "Previous": "너무 이른", + "Previous layer must be image, convert it to raster to apply this tool.": "이전 레이어는 이미지 여야하며이 도구를 적용하려면 래스터로 변환해야합니다.", + "Print": "인쇄", + "Quality:": "품질:", + "Quick Load": "빠른로드", + "Quick Save": "빠른 저장", + "REMOVE.BG - Remove Image Background": "REMOVE.BG-이미지 배경 제거", + "Radial": "방사형", + "Radial gradient": "방사형 그래디언트", + "Radius:": "반지름:", + "Range:": "범위:", + "Red": "빨간", + "Red channel:": "적색 통로:", + "Redo": "다시 하다", + "Remove all": "모두 제거", + "Rename": "이름 바꾸기", + "Rename Layer": "레이어 이름 변경", + "Rendered with errors.": "오류와 함께 렌더링됩니다.", + "Rendering...": "표현...", + "Replace Color": "색상 바꾸기", + "Replace color": "색상 바꾸기", + "Replacement:": "바꿔 놓음:", + "Report Issues": "문제 신고", + "Reset": "다시 놓기", + "Resize": "크기 조정", + "Resize Boundary": "경계 크기 조정", + "Resize Layer": "레이어 크기 조정", + "Resize Layers": "레이어 크기 조정", + "Resize Text Layer": "텍스트 레이어 크기 조정", + "Resized as background": "배경으로 크기 조정", + "Resized:": "크기 조정됨:", + "Resolution:": "해결:", + "Restore Alpha": "알파 복원", + "Right": "권리", + "Right angle:": "직각:", + "Right to Left": "오른쪽에서 왼쪽으로", + "Rotate": "회전", + "Rotate Layer": "레이어 회전", + "Rotate is not supported on this type of object. Convert to raster?": "회전은이 유형의 객체에서 지원되지 않습니다. 래스터로 변환 하시겠습니까?", + "Rotate left": "왼쪽으로 회전", + "Rotate:": "회전 :", + "Ruler": "자", + "SQUOOSH - Compress and Compare Images": "SQUOOSH-이미지 압축 및 비교", + "Saturate": "가득한", + "Saturation": "포화", + "Saturation:": "포화:", + "Save As": "다른 이름으로 저장", + "Save As Data URL": "데이터 URL로 저장", + "Save as": "다른 이름으로 저장", + "Save as type:": "유형으로 저장 :", + "Save layers:": "레이어 저장 :", + "Scaling up is not supported in Hermite, using Lanczos.": "Lanczos를 사용하는 Hermite에서는 확장이 지원되지 않습니다.", + "Scroll down": "아래로 스크롤", + "Scroll up": "스크롤", + "Search": "수색", + "Search Images": "이미지 검색", + "Search for Font": "글꼴 검색", + "Search:": "찾다:", + "Select All": "모두 선택", + "Select Text Layer": "텍스트 레이어 선택", + "Select object tool": "오브젝트 도구 선택", + "Selected": "선택된", + "Selection Tool": "선택 도구", + "Sensitivity:": "감광도:", + "Separated": "분리됨", + "Separated (original types)": "분리형(원본 유형)", + "Sepia": "세피아", + "Set Image Size": "이미지 크기 설정", + "Settings": "설정", + "Shadow": "그림자", + "Shapes": "모양", + "Shapes (H)": "모양(H)", + "Sharpen": "갈다", + "Sharpen Tool": "선명 도구", + "Sharpen:": "갈다:", + "Shift + S": "쉬프트 + S", + "Shortcut Key:": "바로 가기 키:", + "Show": "보여주다", + "Show \/ Hide": "표시 \/ 숨기기", + "Show file size:": "파일 크기 표시 :", + "Simple": "단순한", + "Size is too big, max": "크기가 너무 큽니다.", + "Size:": "크기:", + "Skip - layer must be image.": "건너 뛰기 - 레이어가 이미지 여야합니다.", + "Solarize": "솔라 이즈", + "Sorry, cold not load getUserMedia() data:": "죄송합니다. getUserMedia () 데이터를로드하지 마세요.", + "Sorry, image could not be loaded.": "죄송합니다. 이미지를로드 할 수 없습니다.", + "Sorry, image could not be loaded. Try copy image and paste it.": "죄송합니다. 이미지를로드 할 수 없습니다. 이미지 복사 및 붙여 넣기를 시도하십시오.", + "Sorry, image is too big, max 5 MB.": "죄송합니다. 이미지가 너무 크고 최대 5MB입니다.", + "Source coordinates saved.": "소스 좌표가 저장되었습니다.", + "Source is empty, right click on image or use long press to save source position.": "소스가 비어 있습니다. 이미지를 마우스 오른쪽 버튼으로 클릭하거나 길게 눌러 소스 위치를 저장하세요.", + "Sprites": "스프라이트", + "Square": "광장", + "Stream:": "흐름:", + "Strength:": "힘:", + "Strict": "엄격한", + "TINYPNG - Compress PNG and JPEG": "TINYPNG-PNG 및 JPEG 압축", + "Tab": "탭", + "Tag Image File Format": "태그 이미지 파일 형식", + "Tahoma": "타호 마", + "Target:": "목표:", + "The quick brown fox jumps over the lazy dog.": "날렵한 갈색여우가 게으른 개를 뛰어넘습니다.", + "There": "그곳에", + "There are no layers behind.": "뒤에 레이어가 없습니다.", + "There is only 1 layer.": "단 하나의 레이어가 있습니다.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "이 레이어에는 이미지가 있어야합니다. 이 도구를 적용하려면 래스터로 변환하십시오.", + "Tilt Shift": "경사 변화", + "Times New Roman": "Times New Roman", + "Toaster": "토스터에", + "Toggle": "비녀장", + "Toggle Color Channels": "색상 채널 전환", + "Toggle Color Picker": "색상 선택기 전환", + "Toggle Menu": "토글 메뉴", + "Toggle Swatches": "견본 전환", + "Tools": "도구들", + "Top": "상단", + "Top to Bottom": "위에서 아래로", + "Total pixels:": "총 픽셀 수 :", + "Translate": "옮기다", + "Translate Layer": "레이어 번역", + "Translate error, can not find dictionary:": "번역 오류, 사전을 찾을 수 없음 :", + "Transparent:": "투명한:", + "Trim": "손질", + "Trim Layers": "레이어 트림", + "Trim borders:": "테두리 자르기 :", + "Trim layer:": "레이어 다듬기 :", + "Trim white color?": "흰색을 다듬을까요?", + "Type:": "유형:", + "Türkçe": "Türkçe", + "Undo": "끄르다", + "Unique colors:": "독특한 색상 :", + "Up": "쪽으로", + "Update": "업데이트", + "Update Brush Layer": "브러시 레이어 업데이트", + "Update Pencil Layer": "연필 레이어 업데이트", + "Update guides": "가이드 업데이트", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Ctrl + V 키보드 단축키를 사용하여 클립 보드에서 붙여 넣기하십시오.", + "V Radius:": "V 반경 :", + "V. Align:": "V. 정렬 :", + "Valencia": "발렌시아", + "Verdana": "Verdana", + "Version:": "번역:", + "Vertical": "수직선", + "Vertical Alignment": "수직 정렬", + "Vertical blur:": "수직 흐림 효과 :", + "Vertical:": "수직의:", + "Vibrance": "활기찬", + "View": "보다", + "Vignette": "삽화", + "ViliusL": "ViliusL", + "Vintage": "포도 수확", + "Webcam": "웹캠", + "Webcam #": "웹캠 #", + "Website:": "웹 사이트 :", + "Weppy File Format": "Weppy 파일 형식", + "Width (%):": "너비 (%) :", + "Width:": "폭:", + "Windows Bitmap": "Windows 비트 맵", + "Word": "워드", + "Word + Letter": "단어 + 문자", + "Wrap At:": "줄 바꿈 :", + "Wrap:": "싸다:", + "Wrong dimensions": "잘못된 치수", + "Wrong file type, must be image or json.": "잘못된 파일 유형. 이미지 또는 json이어야합니다.", + "X end:": "X 끝 :", + "X position:": "X 위치 :", + "X start:": "X 시작 :", + "X-Pro II": "X-Pro II", + "Y end:": "Y 끝 :", + "Y position:": "Y 위치 :", + "Y start:": "Y 시작 :", + "You can also drag and drop items into browser.": "항목을 브라우저로 끌어다 놓을 수도 있습니다.", + "Your browser does not support canvas or JavaScript is not enabled.": "브라우저가 캔버스를 지원하지 않거나 JavaScript가 활성화되어 있지 않습니다.", + "Your browser does not support this format.": "브라우저가이 형식을 지원하지 않습니다.", + "Your search did not match any images.": "검색어와 일치하는 이미지가 없습니다.", + "Zoom": "줌", + "Zoom Blur": "줌 블러", + "Zoom In": "확대", + "Zoom Out": "축소", + "Zoom blur": "줌 흐림 효과", + "Zoom in": "확대", + "Zoom out": "축소", + "Zoom:": "줌:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/lt.json b/paintplus/frontend/src/js/languages/lt.json new file mode 100644 index 0000000..5d03684 --- /dev/null +++ b/paintplus/frontend/src/js/languages/lt.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Pašalinant anuliavimo istoriją įvyko problema. Tai", + "About": "Apie", + "Active": "Aktyvus", + "Aden": "Aden", + "Advanced": "Pažangus", + "All": "Visi", + "Alpha": "Alfa", + "Alpha:": "Alfa:", + "Anonymous": "Anoniminis", + "Anti aliasing": "Sulieti", + "Application markup may have changed,": "Programos žymėjimas galėjo pasikeisti,", + "Arial": "Arial", + "Arrow": "Rodyklė", + "ArrowDown": "Rodyklė žemyn", + "ArrowLeft": "Rodyklė kairėn", + "ArrowRight": "RodyklėDešinė", + "ArrowUp": "„ArrowUp“", + "Author:": "Autorius:", + "Auto Adjust Colors": "Sureguliuoti spalvas", + "Auto Kerning": "„Auto Kerning“", + "Average:": "Vidurkis:", + "Backspace": "Backspace", + "Base": "Bazė", + "Basic": "Paprastas", + "Black and White": "Juoda ir balta", + "Blue": "Mėlynas", + "Blue channel:": "Mėlyna kanalas:", + "Blueprint": "Techninis piešinys", + "Blur Radius:": "Migla spindulys:", + "Blur Tool": "Neryškus įrankis", + "Blur power:": "Blur stiprumas:", + "Borders": "Ribojasi", + "Bottom": "Apačia", + "Bottom to Top": "Iš apačios į viršų", + "Bounds:": "Ribos:", + "Box": "Dėžė", + "Box Blur": "Box Blur", + "Box blur": "Langelis blur", + "Brightness": "Ryškumas", + "Brightness:": "Ryškumas:", + "Bulge\/Pinch Tool": "Išsipūtimo \/ prispaudimo įrankis", + "Burn": "Deginti", + "Can not animate 1 layer.": "Negalima animuoti 1 sluoksniu.", + "Can not find previous layer.": "Negaliu rasti ankstesnio sluoksnio.", + "Can not use this tool on current layer: image already takes all area.": "Negalima naudoti šio įrankio dabartiniame sluoksnyje: vaizdas jau užima visą plotą.", + "Cancel": "Atšaukti", + "Canvas Size": "Paveikslo Dydis", + "Center": "Centras", + "Center x:": "Centras x:", + "Center y:": "Centras y:", + "Center:": "Centras:", + "Change Composition": "Keisti kompoziciją", + "Change Layer Details": "Keisti išsamią informaciją", + "Change Opacity": "Pakeiskite neskaidrumą", + "Channel:": "Kanalas:", + "Circle": "Ratas", + "Clarendon": "Klarendonas", + "Clear": "Aiškus", + "Clear Selection": "Išvalyti pasirinkimą", + "Clone Tool": "Klonų įrankis", + "Clone count:": "Klonų skaičius:", + "Clone tool disabled for resized image. Please rasterize first.": "Klonavimo įrankis išjungtas norint pakeisti vaizdo dydį. Pirmiausia rastruokite.", + "Cloned edges": "Klonuoti kraštai", + "Close": "Uždaryti", + "Color #": "Spalva #", + "Color Corrections": "Spalvų korekcijos", + "Color Palette": "Spalvų paletė", + "Color Zoom": "Spalvų mastelio keitimas", + "Color alpha value can not be zero.": "Spalvų alfa vertė negali būti lygi nuliui.", + "Color to Alpha": "Spalva alfa", + "Color zoom": "Spalvų priartinimas", + "Color:": "Spalva:", + "Colors": "Spalvos", + "Colors:": "Spalvos:", + "Common Filters": "Bendri filtrai", + "Composition": "Kompozicija", + "Composition:": "Sudėtis:", + "Content Fill": "Turinio užpildymas", + "Contrast": "Kontrastas", + "Contrast:": "Kontrastas:", + "Convert layer to raster": "Konvertuoti sluoksnį į rastrinį", + "Convert to Raster": "Konvertuoti į rastrą", + "Copy Selection": "Kopijuoti pasirinkimą", + "Copy to Clipboard": "Nukopijuoti į iškarpinę", + "Courier": "Courier", + "Crop Tool": "Apkarpymo įrankis", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Apkarpyti pasuktą sluoksnį negalima. Konvertuokite jį į rastrą, kad galėtumėte tęsti.", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl+A", + "Ctrl+C": "Ctrl+C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl+V", + "Ctrl+Y": "Ctrl+Y", + "Ctrl+Z": "Ctrl+Z", + "Current": "Dabartinis", + "Current Color Preview": "Dabartinė spalvų peržiūra", + "Custom": "Kitas", + "Data URL": "Duomenų adresas", + "Data URL:": "Duomenų adresas:", + "Decrease": "Mažinti", + "Decrease Color Depth": "Sumažinti spalvų gylį", + "Degree:": "Laipsnis:", + "Del": "Del", + "Delete": "Ištrinti", + "Delete Selection": "Ištrinti pasirinkimą", + "Denoise": "Sumažinti triukšmą", + "Desaturate Tool": "Desaturato įrankis", + "Description:": "Aprašymas:", + "Deutsch": "Deutsch", + "Differences": "Skirtumai", + "Differences Down": "Skirtumai žemyn", + "Direction:": "Kryptis:", + "Dither": "Papildymas", + "Dithering:": "Papildymu:", + "Dominant color:": "Dominuojanti spalva:", + "Dot Screen": "Taškų ekranas", + "Down": "Žemyn", + "Duplicate": "Pasikartojantis", + "Duplicate Layer": "Pasikartojantis sluoksnis", + "Duplicate layer": "Dubliuoti sluoksnį", + "Dynamic": "Dinamiškas", + "Edge": "Kraštas", + "Edit": "Redaguoti", + "Edit text...": "Redaguoti tekstą ...", + "Effect browser": "Poveikio naršyklė", + "Effects": "Efektai", + "Effects browser": "Efektų naršyklė", + "Email:": "El. paštas:", + "Emboss": "Įspausti", + "Empty selection": "Tuščias pasirinkimas", + "Empty selection or type not image.": "Tuščias pasirinkimas arba įveskite ne vaizdą.", + "Enable autoresize:": "Įjungti automatinį dydžio nustatymą:", + "End": "Galas", + "English": "Anglų", + "English (UK)": "anglų (JK)", + "Enrich": "Praturtinti", + "Enter": "Įveskite", + "Erase Tool": "Ištrinti įrankį", + "Erase on rotate object is disabled. Please rasterize first.": "Ištrynimas sukant objektą išjungtas. Pirmiausia rastruokite.", + "Error": "Klaida", + "Error connecting to service.": "Klaida prisijungiant prie paslaugos.", + "Error loading the list of fonts from Google.": "Įkeliant šriftų sąrašą iš „Google“ įvyko klaida.", + "Error registering service worker": "Klaida registruojant aptarnavimo darbuotoją", + "Error: can not find filter:": "Klaida: nepavyksta rasti filtro:", + "Error: can not find layer with id:": "Klaida: nepavyksta rasti sluoksnio su ID:", + "Error: missing details event target": "Klaida: trūksta detalių įvykio tikslo", + "Error: unknown layer type:": "Klaida: nežinomas sluoksnio tipas:", + "Error: unsupported attribute type:": "Klaida: nepalaikomas atributo tipas:", + "Esc": "Esc", + "Escape": "Pabegti", + "Español": "Español", + "Expand edges": "Išskleiskite kraštus", + "Exponent:": "Eksponentė:", + "Export": "Eksportuoti", + "External": "Išorinis", + "Factor:": "Veiksnys:", + "File": "Failas", + "File name:": "Failo pavadinimas:", + "File size:": "Failo dydis:", + "Fill": "Pildyti", + "Fill Tool": "Užpildymo įrankis", + "Fit": "Talpinti", + "Fit Window": "Tinkamas langas", + "Fit window": "Pritaikyti langą", + "Flatten Image": "Išlyginti vaizdą", + "Flip": "Apversti", + "FloydSteinberg-serpentine": "Floydsteinberg-serpentinas", + "Font": "Šriftas", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "Per visą ekraną", + "Full layers data": "Visų sluoksnių duomenys", + "Gap:": "Atotrūkis:", + "Gaussian Blur": "Gauso suliejimo", + "Gif delay:": "Gif delsimas:", + "Gingham": "Gingamas", + "GitHub:": "Github:", + "Gradient Radius:": "Gradientas spindulys:", + "Grains": "Grūdėtumas", + "Graphics Interchange Format": "Grafikos mainų formatas", + "Gray": "Pilkas", + "Grayscale": "Pelės skalė", + "Greek": "graikų", + "Green": "Žalias", + "Green channel:": "Žalias kanalas:", + "Greyscale:": "Pilkieji pustoniai:", + "Grid": "Tinklelis", + "Grid on\/off": "Tinklelis", + "Guides": "Vadovai", + "Guides enabled.": "Vadovai įjungti.", + "H Radius:": "H spindulys:", + "H. Align:": "H. Lygiuoti:", + "Heatmap": "Spalvinė diagrama", + "Height (%):": "Aukštis (%):", + "Height:": "Aukštis:", + "Help": "Pagalba", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Slėpti", + "Histogram": "Histograma", + "Histogram:": "Histograma:", + "Home": "Namai", + "Horizontal": "Horizontali", + "Horizontal Alignment": "Horizontalus išlyginimas", + "Horizontal blur:": "Horizontalus miglotas vaizdas:", + "Horizontal:": "Horizontalus:", + "Hue": "Atspalvis", + "Hue Rotate": "Atspalvis pasukti", + "Hue:": "Atspalvis:", + "Image": "Vaizdas", + "Image data with multi-layers. Can be opened using miniPaint -": "Vaizdo duomenys su kelių sluoksnių. gali būti atidarytas naudojant minipaint -", + "Impact": "Poveikis", + "In proportion:": "Proporcingai:", + "Increase": "Padidinti", + "Information": "Informacija", + "Inkwell": "Rašalo kasykla", + "Insert": "Įdėti", + "Insert guides": "Įdėkite vadovus", + "Insert new layer": "Įdėkite naują sluoksnį", + "Instagram Filters": "„Instagram“ filtrai", + "Invalid Hex Code": "Netinkamas šešiakampis kodas", + "Italiano": "Italų kalba", + "JPG\/JPEG Format": "JPG \/ JPEG formatas", + "Kerning:": "Kerningas:", + "Key-Points": "Pagrindiniai klausimai", + "KeyU": "KeyU", + "Keyboard Shortcuts": "Klaviatūros nuorodos", + "Keyword:": "Raktinis žodis:", + "Lanczos": "Lanczos", + "Landscape": "Peizažas", + "Language": "Kalba", + "Last modified": "Paskutinį kartą keistas", + "Layer": "Sluoksnis", + "Layer details": "Sluoksnio detalės", + "Layer is empty.": "Sluoksnis tuščias.", + "Layer is not compatible with resize": "Sluoksnis nesuderinamas su dydžio keitimu", + "Layer is vector, convert it to raster to apply this tool.": "Sluoksnis yra vektorius, konvertuokite jį į rastrą, kad pritaikytumėte šį įrankį.", + "Layers": "Sluoksniai", + "Layers:": "Sluoksniai:", + "Layout:": "Išdėstymas:", + "Left": "Kairėje", + "Left to Right": "Iš kairės į dešinę", + "Level:": "Lygis:", + "Levels:": "Lygiais:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Skaisčio:", + "Luminosity": "Šviesumas", + "Magic Eraser Tool": "„Magic Eraser“ įrankis", + "Merge Down": "Sujungti žemyn", + "Merge Layers": "Sujungti sluoksnius", + "Merged": "Sujungta", + "Metrics": "Metrika", + "Middle": "Vidurinis", + "Missing at least 1 size parameter.": "Trūksta bent 1 dydžio parametro.", + "Missing permissions to write to Clipboard.cc": "Trūksta leidimų rašyti į „Clipboard.cc“", + "Mode:": "Režimas:", + "Module function not found.": "Modulio funkcija nerasta.", + "Modules class not found:": "Modulio klasė nerasta:", + "Monospace": "Monospace", + "Mosaic": "Mozaika", + "Mouse:": "Pelė:", + "Move": "Perkelti", + "Move Layer": "Perkelti sluoksnį", + "Move layer down": "Perkelkite sluoksnį žemyn", + "Move layer up": "Perkelti sluoksnį aukštyn", + "Name:": "Vardas:", + "Negative": "Neigiamas", + "New": "Naujas", + "New Bezier Layer": "Naujas Bezier sluoksnis", + "New Brush Layer": "Naujas teptuko sluoksnis", + "New Ellipse Layer": "Naujas elipsės sluoksnis", + "New File": "Naujas failas", + "New Gradient Layer": "Naujas gradiento sluoksnis", + "New Layer": "Naujas sluoksnis", + "New Line Layer": "Naujas eilutės sluoksnis", + "New Pencil Layer": "Naujas pieštukų sluoksnis", + "New Polygon Layer": "Naujas daugiakampio sluoksnis", + "New Rectangle Layer": "Naujas stačiakampio sluoksnis", + "New Text Layer": "Naujas teksto sluoksnis", + "New file": "Naujas failas", + "New from Selection": "Nauja iš pasirinkimo", + "New layer": "Nauja sluoksnis", + "Next": "Kitas", + "Night Vision": "Naktinis matymas", + "None": "Nė vienas", + "Nothing is selected.": "Niekas nėra pasirinktas.", + "Offset X:": "Nuokrypis x:", + "Offset Y:": "Kompensuoti:", + "Oil": "Aliejus", + "Ok": "Gerai", + "Online image editor.": "Internetinis vaizdo redaktorius.", + "Opacity": "Nepermatomumas", + "Opacity:": "Nepermatomumas:", + "Open": "Atidaryti", + "Open Data URL": "Atidaryti duomenų URL", + "Open Directory": "Atidaryti katalogą", + "Open File": "Atidaryti failą", + "Open File Data URL": "Atidarykite failo duomenų URL", + "Open File URL": "Atidarykite failo URL", + "Open File Webcam": "Atidarykite „File Webcam“", + "Open Image": "Atidarykite vaizdą", + "Open JSON File": "Atidarykite JSON failą", + "Open Test Template": "Atidarykite testavimo šabloną", + "Open URL": "Atidaryti url", + "Open data URL": "Atidaryti duomenų url", + "Open from Webcam": "Atidarykite iš interneto kameros", + "Original Size": "Originalus dydis", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - konvertuoti vaizdą į SVG", + "PageDown": "„PageDown“", + "PageUp": "Į viršų", + "Palette": "Paletė", + "Parameter #1:": "Parametras Nr. 1:", + "Parameter #2:": "Parametras # 2:", + "Paste": "Įkelti", + "Pencil": "Pieštukas", + "Percentage:": "Procentas:", + "Pixels:": "Taškai:", + "Placeholder comment for color channels": "Spalvotų kanalų vietos rezervatorius", + "Placeholder comment for color picker": "Spalvų parinkiklio vietos komentaras", + "Placeholder comment for color swatches": "Spalvų pavyzdžių vietos komentaras", + "Portable Network Graphics": "Nešiojama tinklo grafika", + "Portrait": "Portretas", + "Português": "Português", + "Position:": "Padėtis:", + "Power:": "Galia:", + "Preview": "Peržiūrėti", + "Previous": "Ankstesnis", + "Previous layer must be image, convert it to raster to apply this tool.": "Ankstesnis sluoksnis turi būti vaizdas, konvertuoti jį į rastrą, kad būtų taikomas šis įrankis.", + "Print": "Spausdinti", + "Quality:": "Kokybė:", + "Quick Load": "Greitas įkrovimas", + "Quick Save": "Greitas išsaugojimas", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Pašalinti vaizdo foną", + "Radial": "Radialinis", + "Radial gradient": "Radialinis gradientas", + "Radius:": "Spindulys:", + "Range:": "Kategorijos:", + "Red": "Raudonas", + "Red channel:": "Raudonasis kanalas:", + "Redo": "Perdaryti", + "Remove all": "Pašalinti visus", + "Rename": "Pervadinti", + "Rename Layer": "Pervardyti sluoksnį", + "Rendered with errors.": "Pateikta su klaidomis.", + "Rendering...": "Perduodama ...", + "Replace Color": "Pakeiskite spalvą", + "Replace color": "Pakeiskite spalvą", + "Replacement:": "Pakeitimas:", + "Report Issues": "Pranešti apie problemas", + "Reset": "Atstatyti", + "Resize": "Keisti dydį", + "Resize Boundary": "Keisti ribos dydį", + "Resize Layer": "Keisti sluoksnio dydį", + "Resize Layers": "Keisti sluoksnių dydį", + "Resize Text Layer": "Keisti teksto sluoksnio dydį", + "Resized as background": "Pakeista kaip fonas", + "Resized:": "Pakeistas dydis:", + "Resolution:": "Rezoliucija:", + "Restore Alpha": "Atkurti alfa", + "Right": "Teisingai", + "Right angle:": "Dešinysis kampas:", + "Right to Left": "Iš dešinės į kairę", + "Rotate": "Sukti", + "Rotate Layer": "Pasukti sluoksnį", + "Rotate is not supported on this type of object. Convert to raster?": "Šio tipo objektuose nepavyksta pakeisti rotacijos. konvertuoti į rastrą?", + "Rotate left": "Pasukti į kairę", + "Rotate:": "Pasukti:", + "Ruler": "Valdovas", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - suspauskite ir palyginkite vaizdus", + "Saturate": "Saturate", + "Saturation": "Sodrumas", + "Saturation:": "Spalvingumas:", + "Save As": "Išsaugoti kaip", + "Save As Data URL": "Išsaugoti kaip duomenų URL", + "Save as": "Išsaugoti kaip", + "Save as type:": "Išsaugoti kaip:", + "Save layers:": "Išsaugoti sluoksnius:", + "Scaling up is not supported in Hermite, using Lanczos.": "„Hermite“, naudojant „Lanczos“, mastelio didinimas nepalaikomas.", + "Scroll down": "Slinkti žemyn", + "Scroll up": "Slinkite aukštyn", + "Search": "Paieška", + "Search Images": "Ieškoti vaizdų", + "Search for Font": "Ieškoti šrifto", + "Search:": "Paieška:", + "Select All": "Pasirinkti viską", + "Select Text Layer": "Pasirinkite Teksto sluoksnis", + "Select object tool": "Pasirinkite objektas įrankis", + "Selected": "Pasirinkti", + "Selection Tool": "Pasirinkimo įrankis", + "Sensitivity:": "Jautrumas:", + "Separated": "Atskirtas", + "Separated (original types)": "Atskirti (originali tipai)", + "Sepia": "Sepia", + "Set Image Size": "Nustatykite vaizdo dydį", + "Settings": "Nustatymai", + "Shadow": "Šešėlis", + "Shapes": "Formos", + "Shapes (H)": "Formos (H)", + "Sharpen": "Pagaląsti", + "Sharpen Tool": "Aštrinimo įrankis", + "Sharpen:": "Paryškinti:", + "Shift + S": "Shift + S", + "Shortcut Key:": "Spartusis klavišas:", + "Show": "Rodyti", + "Show \/ Hide": "Rodyti \/ Slėpti", + "Show file size:": "Rodyti failo dydį:", + "Simple": "Paprastas", + "Size is too big, max": "Dydis yra per didelis, maks", + "Size:": "Dydis:", + "Skip - layer must be image.": "Praleisti - sluoksnis turi būti vaizdas.", + "Solarize": "Soliarizacija", + "Sorry, cold not load getUserMedia() data:": "Deja, šalta, neįkelkite „getUserMedia“ () duomenų:", + "Sorry, image could not be loaded.": "Deja, nepavyko įkelti vaizdo.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Deja, vaizdas negali būti įkeltas. pabandykite kopijuoti nuotrauką ir įklijuoti ją.", + "Sorry, image is too big, max 5 MB.": "Atsiprašome, vaizdas yra per didelis, daugiausiai 5 MB.", + "Source coordinates saved.": "Šaltinio koordinatės išsaugotos.", + "Source is empty, right click on image or use long press to save source position.": "Šaltinis tuščias, dešiniuoju pelės mygtuku spustelėkite vaizdą arba naudokite ilgą paspaudimą, kad išsaugotumėte šaltinio padėtį.", + "Sprites": "Sprites", + "Square": "Langelis", + "Stream:": "Srautas:", + "Strength:": "Jėga:", + "Strict": "Griežtas", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - suspausti PNG ir JPEG", + "Tab": "Tab", + "Tag Image File Format": "Žymės vaizdo failo formatas", + "Tahoma": "Tahoma", + "Target:": "Tikslas:", + "The quick brown fox jumps over the lazy dog.": "Greita rudoji lapė peršoka per tinginį šunį.", + "There": "Ten", + "There are no layers behind.": "Už sluoksnių nėra.", + "There is only 1 layer.": "Yra tik 1 sluoksnis.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Sluoksnis turi būti paveiksliukas, konvertuokite jį į rastrą, kad pritaikyti šį įrankį.", + "Tilt Shift": "Tento perkelimas", + "Times New Roman": "Times New Roman", + "Toaster": "Skrudintuvas", + "Toggle": "Perjungti", + "Toggle Color Channels": "Perjungti spalvų kanalus", + "Toggle Color Picker": "Perjungti spalvų rinkiklį", + "Toggle Menu": "Perjungti meniu", + "Toggle Swatches": "Perjungti pavyzdžius", + "Tools": "Įrankiai", + "Top": "Į viršų", + "Top to Bottom": "Nuo viršaus iki apačios", + "Total pixels:": "Iš viso taškų:", + "Translate": "Versti", + "Translate Layer": "Versti sluoksnį", + "Translate error, can not find dictionary:": "Versti klaidą, negali rasti žodyną:", + "Transparent:": "Skaidri:", + "Trim": "Apkarpyti", + "Trim Layers": "Apdailos sluoksniai", + "Trim borders:": "Apkirpti kraštus:", + "Trim layer:": "Trim sluoksnis:", + "Trim white color?": "Trim balta spalva?", + "Type:": "Tipas:", + "Türkçe": "Türkçe", + "Undo": "Anuliuoti", + "Unique colors:": "Unikalios spalvos:", + "Up": "Aukštyn", + "Update": "Atnaujinti", + "Update Brush Layer": "Atnaujinti teptuko sluoksnį", + "Update Pencil Layer": "Atnaujinkite pieštukų sluoksnį", + "Update guides": "Atnaujinti vadovus", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Naudokite \"ctrl + v\" spartieji klavišai, kuriuos norite įklijuoti iš iškarpinės.", + "V Radius:": "V spindulys:", + "V. Align:": "V. Sulyginti:", + "Valencia": "Valensija", + "Verdana": "Verdana", + "Version:": "Versija:", + "Vertical": "Vertikalus", + "Vertical Alignment": "Vertikalus išlyginimas", + "Vertical blur:": "Vertikalus plyšimas:", + "Vertical:": "Vertikalus:", + "Vibrance": "Rezonansas", + "View": "Žiūrėti", + "Vignette": "Vinjetė", + "ViliusL": "Viliusl", + "Vintage": "Senoviškas", + "Webcam": "Internetinė kamera", + "Webcam #": "Internetinė kamera #", + "Website:": "Interneto svetainė:", + "Weppy File Format": "Weppy failo formatas", + "Width (%):": "Plotis (%):", + "Width:": "Plotis:", + "Windows Bitmap": "„Windows Bitmap“", + "Word": "Žodis", + "Word + Letter": "Žodis + laiškas", + "Wrap At:": "Apvyniokite:", + "Wrap:": "Apvyniojimas:", + "Wrong dimensions": "Neteisingi matmenys", + "Wrong file type, must be image or json.": "Neteisingas failo tipas, turi būti paveikslėlis arba json.", + "X end:": "X pabaiga:", + "X position:": "X pozicija:", + "X start:": "X pradžia::", + "X-Pro II": "„X-Pro II“", + "Y end:": "Y pabaiga:", + "Y position:": "Y pozicija:", + "Y start:": "Y pradžia:", + "You can also drag and drop items into browser.": "Taip pat galite vilkti elementus į naršyklę.", + "Your browser does not support canvas or JavaScript is not enabled.": "Jūsų naršyklė nepalaiko drobės ar javascript nėra įjungtas.", + "Your browser does not support this format.": "Jūsų naršyklė nepalaiko šio formato.", + "Your search did not match any images.": "Jūsų paieška neatitiko jokių vaizdų.", + "Zoom": "Zoom", + "Zoom Blur": "Zoom Blur", + "Zoom In": "Priartinti", + "Zoom Out": "Nutolinti", + "Zoom blur": "Padidinti blur", + "Zoom in": "Priartinti", + "Zoom out": "Nutolinti", + "Zoom:": "Priartinimas:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/nl.json b/paintplus/frontend/src/js/languages/nl.json new file mode 100644 index 0000000..a625384 --- /dev/null +++ b/paintplus/frontend/src/js/languages/nl.json @@ -0,0 +1,514 @@ +{ + "A problem occurred while removing undo history. It": "Er is een probleem opgetreden bij het verwijderen van de ongedaanmaakgeschiedenis. Het", + "About": "Over", + "Active": "Actief", + "Aden": "Aden", + "Advanced": "Geavanceerd", + "All": "Alle", + "Alpha": "Alpha", + "Alpha:": "Alpha:", + "Anonymous": "Anoniem", + "Anti aliasing": "Anti-aliasing", + "Application markup may have changed,": "De opmaak van de applicatie is mogelijk gewijzigd,", + "Arial": "Arial", + "Arrow": "Pijl", + "ArrowDown": "ArrowDown", + "ArrowLeft": "Pijl naar links", + "ArrowRight": "Pijl naar rechts", + "ArrowUp": "Pijl omhoog", + "Author:": "Auteur:", + "Auto Adjust Colors": "Automatisch kleuren aanpassen", + "Auto Kerning": "Automatisch letterafstand aanpassen", + "Average:": "Gemiddelde:", + "Backspace": "Rugpijn", + "Base": "Basis", + "Basic": "BASIS", + "Black and White": "Zwart en Wit", + "Blue": "Blauw", + "Blue channel:": "Blauw kanaal:", + "Blueprint": "Blauwdruk", + "Blur Radius:": "Vervagingsstraal:", + "Blur Tool": "Vervagingsgereedschap", + "Blur power:": "Vervagingskracht:", + "Borders": "Randen", + "Bottom": "Onderkant", + "Bottom to Top": "Van onder naar boven", + "Bounds:": "Grenzen:", + "Box": "Doos", + "Box Blur": "Doos vervagen", + "Box blur": "Doos vervagen", + "Brightness": "Helderheid", + "Brightness:": "Helderheid:", + "Bulge\/Pinch Tool": "Uitzetten\/knijpen gereedschap", + "Burn": "Branden", + "Can not animate 1 layer.": "Kan geen 1 laag animeren.", + "Can not find previous layer.": "Kan de vorige laag niet vinden.", + "Can not use this tool on current layer: image already takes all area.": "Kan dit gereedschap niet gebruiken op de huidige laag: de afbeelding neemt al het hele gebied in beslag.", + "Cancel": "Annuleren", + "Canvas Size": "Canvas grootte", + "Center": "Midden", + "Center x:": "Middelpunt x:", + "Center y:": "Middelpunt y:", + "Center:": "Midden:", + "Change Composition": "Compositie wijzigen", + "Change Layer Details": "Laagdetails wijzigen", + "Change Opacity": "Wijzig de dekking", + "Channel:": "Kanaal:", + "Circle": "Cirkel", + "Clarendon": "Clarendon", + "Clear": "Wissen", + "Clear Selection": "Selectie wissen", + "Clone Tool": "Kloon gereedschap", + "Clone count:": "Aantal klonen:", + "Clone tool disabled for resized image. Please rasterize first.": "Kloontool uitgeschakeld voor afbeelding met gewijzigd formaat. Gelieve eerst te rasteren.", + "Cloned edges": "Gekloonde randen", + "Close": "Dichtbij", + "Color #": "Kleur #", + "Color Corrections": "Kleurcorrecties", + "Color Palette": "Kleurenpalet", + "Color Zoom": "Kleurzoom", + "Color alpha value can not be zero.": "Kleur alfa-waarde kan niet nul zijn.", + "Color to Alpha": "Kleur naar Alpha", + "Color zoom": "Kleurzoom", + "Color:": "Kleur:", + "Colors": "Kleuren", + "Colors:": "Kleuren:", + "Common Filters": "Gemeenschappelijke filters", + "Composition": "Samenstelling", + "Composition:": "Samenstelling:", + "Content Fill": "Inhoud vullen", + "Contrast": "Contrast", + "Contrast:": "Contrast:", + "Convert layer to raster": "Converteer laag naar raster", + "Convert to Raster": "Converteren naar raster", + "Copy Selection": "Selectie kopiëren", + "Copy to Clipboard": "Kopiëren naar klembord", + "Courier": "Koerier", + "Crop Tool": "Bijsnijdgereedschap", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Bijsnijden op geroteerde laag wordt niet ondersteund. Converteer het naar raster om door te gaan.", + "Ctrl + C": "Ctrl+C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "Huidige", + "Current Color Preview": "Huidige kleurvoorbeeld", + "Custom": "Aangepast", + "Data URL": "Gegevens-URL", + "Data URL:": "Gegevens-URL:", + "Decrease": "Verminderen", + "Decrease Color Depth": "Kleurdiepte verminderen", + "Degree:": "Graad:", + "Del": "Del", + "Delete": "Verwijderen", + "Delete Selection": "Selectie verwijderen", + "Denoise": "Ruis verminderen", + "Desaturate Tool": "Ontzadigen gereedschap", + "Description:": "Beschrijving:", + "Deutsch": "Duits", + "Differences": "Verschillen", + "Differences Down": "Verschillen omlaag", + "Direction:": "Richting:", + "Dither": "Dither", + "Dithering:": "Dithering:", + "Dominant color:": "Dominante kleur:", + "Dot Screen": "Puntenscherm", + "Down": "Omlaag", + "Duplicate": "Dupliceren", + "Duplicate Layer": "Dupliceer laag", + "Duplicate layer": "Dubbele laag", + "Dutch": "Nederlands", + "Dynamic": "Dynamisch", + "Edge": "Rand", + "Edit": "Bewerken", + "Edit text...": "Tekst bewerken...", + "Effect browser": "Effectenbrowser", + "Effects": "Effecten", + "Effects browser": "Effectenbrowser", + "Email:": "E-mail:", + "Emboss": "In reliëf", + "Empty selection": "Lege selectie", + "Empty selection or type not image.": "Lege selectie of type geen afbeelding.", + "Enable autoresize:": "Automatisch aanpassen van formaat inschakelen:", + "End": "Einde", + "English": "Engels", + "English (UK)": "Engels (VK)", + "Enrich": "Verrijken", + "Enter": "Invoeren", + "Erase Tool": "Wismiddel", + "Erase on rotate object is disabled. Please rasterize first.": "Wissen bij roteren van object is uitgeschakeld. Gelieve eerst te rasteren.", + "Error": "Fout", + "Error connecting to service.": "Fout bij het verbinden met de service.", + "Error loading the list of fonts from Google.": "Fout bij het laden van de lijst met lettertypen van Google.", + "Error registering service worker": "Fout bij registreren van servicemedewerker", + "Error: can not find filter:": "Fout: kan filter niet vinden:", + "Error: can not find layer with id:": "Fout: kan laag met id niet vinden:", + "Error: missing details event target": "Fout: ontbrekend doelevenementdoel", + "Error: unknown layer type:": "Fout: onbekend laagtype:", + "Error: unsupported attribute type:": "Fout: niet-ondersteund attribuuttype:", + "Esc": "Esc", + "Escape": "Ontsnappen", + "Español": "Spaans", + "Expand edges": "Randen uitbreiden", + "Exponent:": "Exponent:", + "Export": "Exporteren", + "External": "Extern", + "Factor:": "Factor:", + "File": "Bestand", + "File name:": "Bestandsnaam:", + "File size:": "Bestandsgrootte:", + "Fill": "Vullen", + "Fill Tool": "Vulmiddel", + "Fit": "Passend maken", + "Fit Window": "Venster passend maken", + "Fit window": "Venster passen", + "Flatten Image": "Afbeelding afvlakken", + "Flip": "Omdraaien", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentijn", + "Font": "Lettertype", + "Français": "Frans", + "Full HD, 1080p": "Volledig HD, 1080p", + "Full Screen": "Volledig scherm", + "Full layers data": "Volledige laaggegevens", + "Gap:": "Spleet:", + "Gaussian Blur": "Gaussische vervaging", + "Gif delay:": "Gif-vertraging:", + "Gingham": "Gingham", + "GitHub:": "GitHub:", + "Gradient Radius:": "Gradiëntradius:", + "Grains": "Korrels", + "Graphics Interchange Format": "Grafische uitwisselingsformaat", + "Gray": "Grijs", + "Grayscale": "Grijstinten", + "Greek": "Grieks", + "Green": "Groen", + "Green channel:": "Groen kanaal:", + "Greyscale:": "Grijstinten:", + "Grid": "Raster", + "Grid on\/off": "Raster aan\/uit", + "Guides": "Gidsen", + "Guides enabled.": "Gidsen ingeschakeld.", + "H Radius:": "H Radius:", + "H. Align:": "H. Uitlijnen:", + "Heatmap": "Warmtekaart", + "Height (%):": "Hoogte (%):", + "Height:": "Hoogte:", + "Help": "Help", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Verbergen", + "Histogram": "Histogram", + "Histogram:": "Histogram:", + "Home": "Start", + "Horizontal": "Horizontaal", + "Horizontal Alignment": "Horizontale uitlijning", + "Horizontal blur:": "Horizontale vervaging:", + "Horizontal:": "Horizontaal:", + "Hue": "Tint", + "Hue Rotate": "Hue Rotate", + "Hue:": "Tint:", + "Image": "Afbeelding", + "Image data with multi-layers. Can be opened using miniPaint -": "Afbeeldingsgegevens met meerdere lagen. Kan worden geopend met miniPaint -", + "Impact": "Impact", + "In proportion:": "In proportie:", + "Increase": "Verhogen", + "Information": "Informatie", + "Inkwell": "Inktpot", + "Insert": "Invoegen", + "Insert guides": "Gidsen plaatsen", + "Insert new layer": "Nieuwe laag invoegen", + "Instagram Filters": "Instagram Filters", + "Invalid Hex Code": "Ongeldige Hex Code", + "Italiano": "Italiaans", + "JPG\/JPEG Format": "JPG\/JPEG Formaat", + "Kerning:": "Kerning:", + "Key-Points": "Belangrijke Punten", + "KeyU": "SleutelU", + "Keyboard Shortcuts": "Sneltoetsen", + "Keyword:": "Sleutelwoord:", + "Lanczos": "Lanczos", + "Landscape": "Landschap", + "Language": "Taal", + "Last modified": "Laatst gewijzigd", + "Layer": "Laag", + "Layer details": "Laagdetails", + "Layer is empty.": "Laag is leeg.", + "Layer is not compatible with resize": "Laag is niet compatibel met formaatwijziging", + "Layer is vector, convert it to raster to apply this tool.": "Laag is vector, converteer deze naar raster om dit gereedschap toe te passen.", + "Layers": "Lagen", + "Layers:": "Lagen:", + "Layout:": "Indeling:", + "Left": "Links", + "Left to Right": "Links naar Rechts", + "Level:": "Niveau:", + "Levels:": "Niveaus:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Luminantie:", + "Luminosity": "Luminositeit", + "Magic Eraser Tool": "Tovergummi", + "Merge Down": "Samenvoegen Omlaag", + "Merge Layers": "Lagen Samenvoegen", + "Merged": "Samengevoegd", + "Metrics": "Metrieken", + "Middle": "Midden", + "Missing at least 1 size parameter.": "Minstens 1 grootteparameter ontbreekt.", + "Missing permissions to write to Clipboard.cc": "Machtigingen ontbreken om naar Clipboard.cc te schrijven", + "Mode:": "Modus:", + "Module function not found.": "Modulefunctie niet gevonden.", + "Modules class not found:": "Modulesklasse niet gevonden:", + "Monospace": "Monospace", + "Mosaic": "Mozaïek", + "Mouse:": "Muis:", + "Move": "Verplaatsen", + "Move Layer": "Laag Verplaatsen", + "Move layer down": "Verplaats laag naar beneden", + "Move layer up": "Verplaats laag naar boven", + "Name:": "Naam:", + "Negative": "Negatief", + "New": "Nieuw", + "New Bezier Layer": "Nieuwe Bezier-laag", + "New Brush Layer": "Nieuwe Kwastlaag", + "New Ellipse Layer": "Nieuwe Ellipslaag", + "New File": "Nieuw Bestand", + "New Gradient Layer": "Nieuwe Gradiëntlaag", + "New Layer": "Nieuwe Laag", + "New Line Layer": "Nieuwe Lijnlaag", + "New Pencil Layer": "Nieuwe Potloodlaag", + "New Polygon Layer": "Nieuwe veelhoeklaag", + "New Rectangle Layer": "Nieuwe Rechthoekige Laag", + "New Text Layer": "Nieuwe Tekstlaag", + "New file": "Nieuw bestand", + "New from Selection": "Nieuw vanuit Selectie", + "New layer": "Nieuwe laag", + "Next": "Volgende", + "Night Vision": "Nachtkijker", + "None": "Geen", + "Nothing is selected.": "Niets is geselecteerd.", + "Offset X:": "Verschuiving X:", + "Offset Y:": "Verschuiving Y:", + "Oil": "Olie", + "Ok": "Oké", + "Online image editor.": "Online afbeelding editor", + "Opacity": "Dekking", + "Opacity:": "Dekking:", + "Open": "Openen", + "Open Data URL": "Open Data-URL", + "Open Directory": "Open Map", + "Open File": "Open Bestand", + "Open File Data URL": "Open Bestand Data-URL", + "Open File URL": "Open Bestand-URL", + "Open File Webcam": "Open Bestand Webcam", + "Open Image": "Open Afbeelding", + "Open JSON File": "Open JSON Bestand", + "Open Test Template": "Open Test Sjabloon", + "Open URL": "Open URL", + "Open data URL": "Open data-URL", + "Open from Webcam": "Open van Webcam", + "Original Size": "Origineel Formaat", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Afbeelding converteren naar SVG", + "PageDown": "Pagina Omlaag", + "PageUp": "Pagina Omhoog", + "Palette": "Palet", + "Parameter #1:": "Parameter #1:", + "Parameter #2:": "Parameter #2:", + "Paste": "Plakken", + "Pencil": "Potlood", + "Percentage:": "Percentage:", + "Pixels:": "Pixels:", + "Placeholder comment for color channels": "Plaatsvervangend commentaar voor kleurkanalen", + "Placeholder comment for color picker": "Plaatsvervangend commentaar voor kleurkiezer", + "Placeholder comment for color swatches": "Plaatsvervangend commentaar voor kleurenstaaltjes", + "Portable Network Graphics": "Portable Network Graphics", + "Portrait": "Portret", + "Português": "Portugees", + "Position:": "Positie:", + "Power:": "Kracht:", + "Preview": "Voorbeeld", + "Previous": "Vorige", + "Previous layer must be image, convert it to raster to apply this tool.": "De vorige laag moet een afbeelding zijn, zet deze om naar raster om deze tool toe te passen.", + "Print": "Afdrukken", + "Quality:": "Kwaliteit:", + "Quick Load": "Snel laden", + "Quick Save": "Snel opslaan", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Verwijder Achtergrond van Afbeelding", + "Radial": "Radiaal", + "Radial gradient": "Radiale gradiënt", + "Radius:": "Straal:", + "Range:": "Bereik:", + "Red": "Rood", + "Red channel:": "Rood kanaal:", + "Redo": "Opnieuw", + "Remove all": "Verwijder alles", + "Rename": "Hernoemen", + "Rename Layer": "Laag hernoemen", + "Rendered with errors.": "Weergegeven met fouten.", + "Rendering...": "Renderen...", + "Replace Color": "Kleur vervangen", + "Replace color": "Kleur vervangen", + "Replacement:": "Vervanging:", + "Report Issues": "Problemen rapporteren", + "Reset": "Resetten", + "Resize": "Formaat wijzigen", + "Resize Boundary": "Formaat van grens wijzigen", + "Resize Layer": "Formaat van laag wijzigen", + "Resize Layers": "Formaat van lagen wijzigen", + "Resize Text Layer": "Formaat van tekstlaag wijzigen", + "Resized as background": "Hernoemd als achtergrond", + "Resized:": "Formaat gewijzigd:", + "Resolution:": "Resolutie:", + "Restore Alpha": "Alfa herstellen", + "Right": "Rechts", + "Right angle:": "Rechte hoek:", + "Right to Left": "Van rechts naar links", + "Rotate": "Roteren", + "Rotate Layer": "Laag roteren", + "Rotate is not supported on this type of object. Convert to raster?": "Roteren wordt niet ondersteund voor dit type object. Omzetten naar raster?", + "Rotate left": "Links roteren", + "Rotate:": "Roteren:", + "Ruler": "Liniaal", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Afbeeldingen comprimeren en vergelijken", + "Saturate": "Verzadigen", + "Saturation": "Verzadiging", + "Saturation:": "Verzadiging:", + "Save As": "Opslaan als", + "Save As Data URL": "Opslaan als gegevens-URL", + "Save as": "Opslaan als", + "Save as type:": "Opslaan als type:", + "Save layers:": "Lagen opslaan:", + "Scaling up is not supported in Hermite, using Lanczos.": "Opschalen wordt niet ondersteund in Hermite, Lanczos wordt gebruikt.", + "Scroll down": "Omlaag scrollen", + "Scroll up": "Omhoog scrollen", + "Search": "Zoeken", + "Search Images": "Afbeeldingen zoeken", + "Search for Font": "Zoek naar lettertype", + "Search:": "Zoekopdracht:", + "Select All": "Alles selecteren", + "Select Text Layer": "Tekstlaag selecteren", + "Select object tool": "Objectgereedschap selecteren", + "Selected": "Geselecteerd", + "Selection Tool": "Selectiegereedschap", + "Sensitivity:": "Gevoeligheid:", + "Separated": "Gescheiden", + "Separated (original types)": "Gescheiden (originele typen)", + "Sepia": "Sepia", + "Set Image Size": "Afbeeldingsgrootte instellen", + "Settings": "Instellingen", + "Shadow": "Schaduw", + "Shapes": "Vormen", + "Shapes (H)": "Vormen (H)", + "Sharpen": "Verscherpen", + "Sharpen Tool": "Verscherpgereedschap", + "Sharpen:": "Verscherpen:", + "Shift + S": "Shift + S", + "Shortcut Key:": "Sneltoets:", + "Show": "Show", + "Show \/ Hide": "Tonen \/ Verbergen", + "Show file size:": "Toon bestandsgrootte:", + "Simple": "Eenvoudig", + "Size is too big, max": "Grootte is te groot, maximaal", + "Size:": "Grootte:", + "Skip - layer must be image.": "Overslaan - laag moet een afbeelding zijn.", + "Solarize": "Solariseren", + "Sorry, cold not load getUserMedia() data:": "Sorry, kon getUserMedia() gegevens niet laden:", + "Sorry, image could not be loaded.": "Sorry, afbeelding kon niet worden geladen.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Sorry, afbeelding kon niet worden geladen. Probeer de afbeelding te kopiëren en te plakken.", + "Sorry, image is too big, max 5 MB.": "Sorry, afbeelding is te groot, maximaal 5 MB.", + "Source coordinates saved.": "Broncoördinaten opgeslagen.", + "Source is empty, right click on image or use long press to save source position.": "Bron is leeg, klik met de rechtermuisknop op de afbeelding of gebruik een lange druk om de bronpositie op te slaan.", + "Sprites": "Sprites", + "Square": "Vierkant", + "Stream:": "Stroom:", + "Strength:": "Kracht:", + "Strict": "Strikt", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Comprimeer PNG en JPEG", + "Tab": "Tabblad", + "Tag Image File Format": "Tag afbeelding bestandsformaat", + "Tahoma": "Tahoma", + "Target:": "Doel:", + "The quick brown fox jumps over the lazy dog.": "De snelle bruine vos springt over de luie hond heen.", + "There": "Daar", + "There are no layers behind.": "Er zijn geen lagen achter.", + "There is only 1 layer.": "Er is slechts 1 laag.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Deze laag moet een afbeelding bevatten. Converteer deze alstublieft naar raster om deze tool toe te passen.", + "Tilt Shift": "Kantelverschuiving", + "Times New Roman": "Times New Roman", + "Toaster": "Broodrooster", + "Toggle": "Schakelen", + "Toggle Color Channels": "Schakel kleurkanalen", + "Toggle Color Picker": "Schakel kleurkiezer", + "Toggle Menu": "Schakel menu", + "Toggle Swatches": "Schakel kleurstalen", + "Tools": "Gereedschappen", + "Top": "Bovenkant", + "Top to Bottom": "Van boven naar beneden", + "Total pixels:": "Totaal aantal pixels:", + "Translate": "Vertalen", + "Translate Layer": "Vertaal laag", + "Translate error, can not find dictionary:": "Vertaalfout, kan woordenboek niet vinden:", + "Transparent:": "Transparant:", + "Trim": "Bijsnijden", + "Trim Layers": "Bijsnijden van lagen", + "Trim borders:": "Bijsnijden van randen:", + "Trim layer:": "Bijsnijden van laag:", + "Trim white color?": "Witte kleur bijsnijden?", + "Type:": "Type:", + "Türkçe": "Türkçe", + "Undo": "Ongedaan maken", + "Unique colors:": "Unieke kleuren:", + "Up": "Omhoog", + "Update": "Update", + "Update Brush Layer": "Werk penseel laag bij", + "Update Pencil Layer": "Werk potlood laag bij", + "Update guides": "Gidsen bijwerken", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Gebruik Ctrl+V sneltoets om te plakken vanaf het Klembord.", + "V Radius:": "V Straal:", + "V. Align:": "V. Uitlijnen:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Versie:", + "Vertical": "Verticaal", + "Vertical Alignment": "Verticale uitlijning", + "Vertical blur:": "Verticaal vervagen:", + "Vertical:": "Verticaal:", + "Vibrance": "Levendigheid", + "View": "Weergave", + "Vignette": "Vignet", + "ViliusL": "ViliusL", + "Vintage": "Vintage", + "Webcam": "Webcam", + "Webcam #": "Webcam #", + "Website:": "Website:", + "Weppy File Format": "Weppy bestandsformaat", + "Width (%):": "Breedte (%):", + "Width:": "Breedte:", + "Windows Bitmap": "Windows Bitmap", + "Word": "Woord", + "Word + Letter": "Woord + Letter", + "Wrap At:": "Omzetten bij:", + "Wrap:": "Omzetten:", + "Wrong dimensions": "Verkeerde afmetingen", + "Wrong file type, must be image or json.": "Verkeerd bestandstype, moet afbeelding of json zijn.", + "X end:": "X eind:", + "X position:": "X positie:", + "X start:": "X start:", + "X-Pro II": "X-Pro II", + "Y end:": "Y eind:", + "Y position:": "Y positie:", + "Y start:": "Y start:", + "You can also drag and drop items into browser.": "U kunt ook items naar de browser slepen en neerzetten.", + "Your browser does not support canvas or JavaScript is not enabled.": "Uw browser ondersteunt geen canvas of JavaScript is niet ingeschakeld.", + "Your browser does not support this format.": "Uw browser ondersteunt dit formaat niet.", + "Your search did not match any images.": "Uw zoekopdracht leverde geen overeenkomende afbeeldingen op.", + "Zoom": "Zoomen", + "Zoom Blur": "Zoomvervaging", + "Zoom In": "Inzoomen", + "Zoom Out": "Uitzoomen", + "Zoom blur": "Zoomvervaging", + "Zoom in": "Inzoomen", + "Zoom out": "Uitzoomen", + "Zoom:": "Zoom:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/pt.json b/paintplus/frontend/src/js/languages/pt.json new file mode 100644 index 0000000..6f968ea --- /dev/null +++ b/paintplus/frontend/src/js/languages/pt.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Ocorreu um problema ao remover o histórico de desfazer. isto", + "About": "Sobre", + "Active": "Ativo", + "Aden": "Aden", + "Advanced": "Avançado", + "All": "Todos", + "Alpha": "Alfa", + "Alpha:": "Alfa:", + "Anonymous": "Anônimo", + "Anti aliasing": "Anti-aliasing", + "Application markup may have changed,": "A marcação do aplicativo pode ter mudado,", + "Arial": "Arial", + "Arrow": "Flecha", + "ArrowDown": "Seta para baixo", + "ArrowLeft": "Seta para a esquerda", + "ArrowRight": "Seta para a direita", + "ArrowUp": "Seta para cima", + "Author:": "Autor:", + "Auto Adjust Colors": "Cores de ajuste automático", + "Auto Kerning": "Auto Kerning", + "Average:": "Média:", + "Backspace": "Backspace", + "Base": "Base", + "Basic": "Básico", + "Black and White": "Preto e branco", + "Blue": "Azul", + "Blue channel:": "Canal azul:", + "Blueprint": "Blueprint", + "Blur Radius:": "Raio de desfoque:", + "Blur Tool": "Ferramenta de desfoque", + "Blur power:": "Intensidade do desfoque:", + "Borders": "Bordas", + "Bottom": "Inferior", + "Bottom to Top": "De baixo para cima", + "Bounds:": "Limites:", + "Box": "Caixa", + "Box Blur": "Desfoque de caixa", + "Box blur": "Desfoque de caixa", + "Brightness": "Brilho", + "Brightness:": "Brilho:", + "Bulge\/Pinch Tool": "Ferramenta Bulge \/ Pinch", + "Burn": "Queimar", + "Can not animate 1 layer.": "Não é possível animar 1 camada.", + "Can not find previous layer.": "Não é possível encontrar a camada anterior.", + "Can not use this tool on current layer: image already takes all area.": "Não é possível usar esta ferramenta na camada atual: a imagem já ocupa toda a área.", + "Cancel": "Cancelar", + "Canvas Size": "Tamanho da tela", + "Center": "Centro", + "Center x:": "Centro x:", + "Center y:": "Centro y:", + "Center:": "Centro:", + "Change Composition": "Alterar composição", + "Change Layer Details": "Alterar os detalhes da camada", + "Change Opacity": "Alterar opacidade", + "Channel:": "Canal:", + "Circle": "Círculo", + "Clarendon": "Clarendon", + "Clear": "Limpar", + "Clear Selection": "Limpar seleção", + "Clone Tool": "Ferramenta Clone", + "Clone count:": "Contagem de clones:", + "Clone tool disabled for resized image. Please rasterize first.": "Ferramenta de clonagem desativada para imagem redimensionada. Por favor, rasterize primeiro.", + "Cloned edges": "Bordas clonadas", + "Close": "Fechar", + "Color #": "Cor #", + "Color Corrections": "Correções de cores", + "Color Palette": "Paleta de cores", + "Color Zoom": "Zoom de cor", + "Color alpha value can not be zero.": "O valor alfa da cor não pode ser zero.", + "Color to Alpha": "Cor para alfa", + "Color zoom": "Zoom de cor", + "Color:": "Cor:", + "Colors": "Cores", + "Colors:": "Cores:", + "Common Filters": "Filtros Comuns", + "Composition": "Composição", + "Composition:": "Composição:", + "Content Fill": "Preenchimento de conteúdo", + "Contrast": "Contraste", + "Contrast:": "Contraste:", + "Convert layer to raster": "Rasterizar camada", + "Convert to Raster": "Rasterizar", + "Copy Selection": "Seleção de cópia", + "Copy to Clipboard": "Copiar para área de transferência", + "Courier": "Courier", + "Crop Tool": "Ferramenta de corte", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "O corte na camada girada não é compatível. Rasterize a camada para continuar.", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl+A", + "Ctrl+C": "Ctrl+C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl+V", + "Ctrl+Y": "Ctrl+Y", + "Ctrl+Z": "Ctrl+Z", + "Current": "Atual", + "Current Color Preview": "Pré-visualização da cor atual", + "Custom": "personalizado", + "Data URL": "URL de dados", + "Data URL:": "URL de dados:", + "Decrease": "Diminuir", + "Decrease Color Depth": "Diminuir a profundidade de cor", + "Degree:": "Grau:", + "Del": "Del", + "Delete": "Excluir", + "Delete Selection": "Excluir seleção", + "Denoise": "Reduzir ruído", + "Desaturate Tool": "Ferramenta de dessaturação", + "Description:": "Descrição:", + "Deutsch": "Deutsch", + "Differences": "Diferenças", + "Differences Down": "Diferenças para baixo", + "Direction:": "Direção:", + "Dither": "Dither", + "Dithering:": "Dithering:", + "Dominant color:": "Cor dominante:", + "Dot Screen": "Tela de ponto", + "Down": "Abaixo", + "Duplicate": "Duplicado", + "Duplicate Layer": "Duplicar Camada", + "Duplicate layer": "Duplicar Camada", + "Dynamic": "Dinâmico", + "Edge": "Borda", + "Edit": "Editar", + "Edit text...": "Editar texto...", + "Effect browser": "Buscador de efeitos", + "Effects": "Efeitos", + "Effects browser": "Buscador de efeitos", + "Email:": "Email:", + "Emboss": "Em relevo", + "Empty selection": "Seleção vazia", + "Empty selection or type not image.": "Seleção vazia ou tipo selecionado não é uma imagem.", + "Enable autoresize:": "Ativar redimensionamento automático:", + "End": "Fim", + "English": "English", + "English (UK)": "English (UK)", + "Enrich": "Enriquecer", + "Enter": "Entrar", + "Erase Tool": "Ferramenta Apagar", + "Erase on rotate object is disabled. Please rasterize first.": "Apagar ao girar o objeto está desativado. Por favor, rasterize o objeto primeiro.", + "Error": "Erro", + "Error connecting to service.": "Erro ao conectar-se ao serviço.", + "Error loading the list of fonts from Google.": "Erro ao carregar a lista de fontes do Google.", + "Error registering service worker": "Erro ao registrar o service worker", + "Error: can not find filter:": "Erro: não foi possível encontrar o filtro:", + "Error: can not find layer with id:": "Erro: não foi possível encontrar camada com id:", + "Error: missing details event target": "Erro: faltam detalhes do alvo do evento", + "Error: unknown layer type:": "Erro: tipo de camada desconhecido:", + "Error: unsupported attribute type:": "Erro: tipo de atributo não suportado:", + "Esc": "Esc", + "Escape": "Escapar", + "Español": "Español", + "Expand edges": "Expandir bordas", + "Exponent:": "Expoente:", + "Export": "Exportar", + "External": "Externo", + "Factor:": "Fator:", + "File": "Arquivo", + "File name:": "Nome do arquivo:", + "File size:": "Tamanho do arquivo:", + "Fill": "Preencher", + "Fill Tool": "Ferramenta de Preenchimento", + "Fit": "Ajustar", + "Fit Window": "Ajuste de Janela", + "Fit window": "Ajustar janela", + "Flatten Image": "Achatar imagem", + "Flip": "Giro", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentine", + "Font": "Fonte", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "Tela cheia", + "Full layers data": "Dados de camadas completas", + "Gap:": "Espaçamento:", + "Gaussian Blur": "Desfoque Gaussiano", + "Gif delay:": "Atraso do GIF:", + "Gingham": "Tecido de algodão", + "GitHub:": "GitHub:", + "Gradient Radius:": "Raio do Gradiente:", + "Grains": "Grãos", + "Graphics Interchange Format": "Formato de intercâmbio de gráficos", + "Gray": "Cinza", + "Grayscale": "Escala de cinza", + "Greek": "Ελληνικά (Greek)", + "Green": "Verde", + "Green channel:": "Canal verde:", + "Greyscale:": "Escala de cinza:", + "Grid": "Grade", + "Grid on\/off": "Grades Ligado \/ Desligado", + "Guides": "Guias", + "Guides enabled.": "Guias ativados.", + "H Radius:": "Raio H.", + "H. Align:": "Alinhamento H.", + "Heatmap": "Mapa de calor", + "Height (%):": "Altura (%):", + "Height:": "Altura:", + "Help": "Ajuda", + "Helvetica": "helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Ocultar", + "Histogram": "Histograma", + "Histogram:": "Histograma:", + "Home": "Início", + "Horizontal": "Horizontal", + "Horizontal Alignment": "Alinhamento horizontal", + "Horizontal blur:": "Desfoque horizontal:", + "Horizontal:": "Horizontal:", + "Hue": "Matiz", + "Hue Rotate": "Rotação de Matiz", + "Hue:": "Matiz:", + "Image": "Imagem", + "Image data with multi-layers. Can be opened using miniPaint -": "Dados de imagem com várias camadas. Pode ser aberto usando o miniPaint -", + "Impact": "Impact", + "In proportion:": "Na proporção:", + "Increase": "Aumentar", + "Information": "Informação", + "Inkwell": "Tinteiro", + "Insert": "Inserir", + "Insert guides": "Inserir guias", + "Insert new layer": "Inserir nova camada", + "Instagram Filters": "Filtros de Instagram", + "Invalid Hex Code": "Código Hex inválido", + "Italiano": "Italiano", + "JPG\/JPEG Format": "Formato JPG \/ JPEG", + "Kerning:": "Kerning:", + "Key-Points": "Pontos-chave", + "KeyU": "KeyU", + "Keyboard Shortcuts": "Atalhos de teclado", + "Keyword:": "Palavra-chave:", + "Lanczos": "Lanczos", + "Landscape": "Paisagem", + "Language": "Idioma", + "Last modified": "Última modificação", + "Layer": "Camada", + "Layer details": "Detalhes da camada", + "Layer is empty.": "A camada está vazia.", + "Layer is not compatible with resize": "Camada não é compatível com redimensionamento", + "Layer is vector, convert it to raster to apply this tool.": "A camada é um vetor, rasterize-a para usar esta ferramenta.", + "Layers": "Camadas", + "Layers:": "Camadas:", + "Layout:": "Disposição:", + "Left": "Esquerda", + "Left to Right": "Da esquerda para direita", + "Level:": "Nível:", + "Levels:": "Níveis:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Luminância:", + "Luminosity": "Luminosidade", + "Magic Eraser Tool": "Ferramenta de borracha mágica", + "Merge Down": "Mesclar para Baixo", + "Merge Layers": "Mesclar Camadas", + "Merged": "Mesclado", + "Metrics": "Métricas", + "Middle": "Meio", + "Missing at least 1 size parameter.": "Falta pelo menos 1 parâmetro de tamanho.", + "Missing permissions to write to Clipboard.cc": "Permissões ausentes para gravar em Clipboard.cc", + "Mode:": "Modo:", + "Module function not found.": "Função do módulo não encontrada.", + "Modules class not found:": "Classe de módulos não encontrada:", + "Monospace": "Monospace", + "Mosaic": "mosaico", + "Mouse:": "Mouse:", + "Move": "Mover", + "Move Layer": "Mover Camada", + "Move layer down": "Mover camada para baixo", + "Move layer up": "Mover camada para cima", + "Name:": "Nome:", + "Negative": "Negativo", + "New": "Novo", + "New Bezier Layer": "Nova camada de Bézier", + "New Brush Layer": "Nova Camada de Pincel", + "New Ellipse Layer": "Nova Camada de Elipse", + "New File": "Novo arquivo", + "New Gradient Layer": "Nova Camada de Gradiente", + "New Layer": "Nova camada", + "New Line Layer": "Nova Camada de Linha", + "New Pencil Layer": "Nova Camada de Lápis", + "New Polygon Layer": "Nova camada de polígono", + "New Rectangle Layer": "Nova Camada de Retângulo", + "New Text Layer": "Nova Camada de Texto", + "New file": "Novo arquivo", + "New from Selection": "Novo da seleção", + "New layer": "Nova camada", + "Next": "Próximo", + "Night Vision": "Visão noturna", + "None": "Nenhum", + "Nothing is selected.": "Não há nada selecionado.", + "Offset X:": "Deslocamento X:", + "Offset Y:": "Deslocamento Y:", + "Oil": "Óleo", + "Ok": "Ok", + "Online image editor.": "Editor de imagens online.", + "Opacity": "Opacidade", + "Opacity:": "Opacidade:", + "Open": "Aberto", + "Open Data URL": "Abrir URL de dados", + "Open Directory": "Diretório aberto", + "Open File": "Abrir arquivo", + "Open File Data URL": "URL de dados de arquivo aberta", + "Open File URL": "URL do arquivo aberta", + "Open File Webcam": "Abrir arquivo da webcam", + "Open Image": "Abrir Imagem", + "Open JSON File": "Abrir arquivo JSON", + "Open Test Template": "Abrir modelo de teste", + "Open URL": "Abrir URL", + "Open data URL": "Abrir URL de dados", + "Open from Webcam": "Abrir na webcam", + "Original Size": "Tamanho original", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Converter imagem para SVG", + "PageDown": "PageDown", + "PageUp": "PageUp", + "Palette": "Paleta", + "Parameter #1:": "Parâmetro #1:", + "Parameter #2:": "Parâmetro #2:", + "Paste": "Colar", + "Pencil": "Lápis", + "Percentage:": "Porcentagem:", + "Pixels:": "Píxeis:", + "Placeholder comment for color channels": "Comentário reservado para canais de cores", + "Placeholder comment for color picker": "Comentário reservado para posição para o seletor de cores", + "Placeholder comment for color swatches": "Comentário reservado para amostras de cores", + "Portable Network Graphics": "Gráficos Portáteis de Rede", + "Portrait": "Retrato", + "Português": "Português", + "Position:": "Posição:", + "Power:": "Poder:", + "Preview": "Pré-visualização", + "Previous": "Anterior", + "Previous layer must be image, convert it to raster to apply this tool.": "A camada anterior deve ser uma imagem, rasterize-a para aplicar esta ferramenta.", + "Print": "Imprimir", + "Quality:": "Qualidade:", + "Quick Load": "Carregamento Rápido", + "Quick Save": "Salvamento Rápido", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Remover fundo da imagem", + "Radial": "Radial", + "Radial gradient": "Gradiente radial", + "Radius:": "Raio:", + "Range:": "Alcance:", + "Red": "Vermelho", + "Red channel:": "Canal vermelho:", + "Redo": "Refazer", + "Remove all": "Remover tudo", + "Rename": "Renomear", + "Rename Layer": "Renomear Camada", + "Rendered with errors.": "Renderizado com erros.", + "Rendering...": "Renderizando...", + "Replace Color": "Substituir Cor", + "Replace color": "Substitua cor", + "Replacement:": "Substituição:", + "Report Issues": "Relatar problemas", + "Reset": "Redefinir", + "Resize": "Redimensionar", + "Resize Boundary": "Redimensionar limite", + "Resize Layer": "Camada de redimensionamento", + "Resize Layers": "Camadas de redimensionamento", + "Resize Text Layer": "Redimensionar Camada de Texto", + "Resized as background": "Redimensionado como plano de fundo", + "Resized:": "Redimensionado:", + "Resolution:": "Resolução:", + "Restore Alpha": "Restaurar alfa", + "Right": "Direita", + "Right angle:": "Ângulo direito:", + "Right to Left": "Direita para a esquerda", + "Rotate": "Rotacionar", + "Rotate Layer": "Rotacionar Camada", + "Rotate is not supported on this type of object. Convert to raster?": "Rotacionar não é suportado neste tipo de objeto. Rasterizar objeto?", + "Rotate left": "Rotacionar à esquerda", + "Rotate:": "Rotacionar:", + "Ruler": "Régua", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Comprimir e comparar imagens", + "Saturate": "Saturar", + "Saturation": "Saturação", + "Saturation:": "Saturação:", + "Save As": "Salvar como", + "Save As Data URL": "Salvar como URL de dados", + "Save as": "Salvar como", + "Save as type:": "Salvar como tipo:", + "Save layers:": "Salvar camadas:", + "Scaling up is not supported in Hermite, using Lanczos.": "O aumento de escala não é compatível com Hermite, usando Lanczos.", + "Scroll down": "Deslize para baixo", + "Scroll up": "Deslize para cima", + "Search": "Pesquisar", + "Search Images": "Pesquisar Imagens", + "Search for Font": "Pesquisar Fonte", + "Search:": "Pesquisar:", + "Select All": "Selecionar tudo", + "Select Text Layer": "Selecione Camada de Texto", + "Select object tool": "Selecione a ferramenta de objeto", + "Selected": "Selecionado", + "Selection Tool": "Ferramenta de Seleção", + "Sensitivity:": "Sensibilidade:", + "Separated": "Separados", + "Separated (original types)": "Separados (tipos originais)", + "Sepia": "Sépia", + "Set Image Size": "Definir tamanho da imagem", + "Settings": "Configurações", + "Shadow": "Sombra", + "Shapes": "Formas", + "Shapes (H)": "Formas (H)", + "Sharpen": "Afiar", + "Sharpen Tool": "Ferramenta de Afiar", + "Sharpen:": "Afiar:", + "Shift + S": "Shift + S", + "Shortcut Key:": "Tecla de atalho:", + "Show": "Mostrar", + "Show \/ Hide": "Mostrar \/ Ocultar", + "Show file size:": "Mostrar tamanho do arquivo:", + "Simple": "Simples", + "Size is too big, max": "O tamanho é muito grande, máximo", + "Size:": "Tamanho:", + "Skip - layer must be image.": "Pular - camada deve ser uma imagem.", + "Solarize": "Solarize", + "Sorry, cold not load getUserMedia() data:": "Desculpe, não foi possível carregar dados de getUserMedia():", + "Sorry, image could not be loaded.": "Desculpe, não foi possível carregar a imagem.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Desculpe, a imagem não pôde ser carregada. Tente copiar a imagem e cole-a.", + "Sorry, image is too big, max 5 MB.": "Desculpe, a imagem é muito grande, ultrapassa o máximo permitido de 5 MB.", + "Source coordinates saved.": "Coordenadas da fonte salvas.", + "Source is empty, right click on image or use long press to save source position.": "A fonte está vazia, clique com o botão direito na imagem ou pressione e segure para salvar a posição da fonte.", + "Sprites": "Sprites", + "Square": "Quadrado", + "Stream:": "Transmissão:", + "Strength:": "Força:", + "Strict": "Estrito", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Compactar PNG e JPEG", + "Tab": "Aba", + "Tag Image File Format": "Formato de Arquivo de Imagem Tag", + "Tahoma": "Tahoma", + "Target:": "Alvo:", + "The quick brown fox jumps over the lazy dog.": "A rápida raposa marrom salta sobre o cachorro preguiçoso.", + "There": "Lá", + "There are no layers behind.": "Não há camadas atrás.", + "There is only 1 layer.": "Existe apenas uma camada.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Esta camada deve ser uma imagem, rasterize-a para aplicar esta ferramenta.", + "Tilt Shift": "Desvio de Inclinação", + "Times New Roman": "Times New Roman", + "Toaster": "Torradeira", + "Toggle": "Alternar", + "Toggle Color Channels": "Alternar canais de cores", + "Toggle Color Picker": "Alternar seletor de cores", + "Toggle Menu": "Alternar menu", + "Toggle Swatches": "Alternar amostras", + "Tools": "Ferramentas", + "Top": "Topo", + "Top to Bottom": "De cima para baixo", + "Total pixels:": "Total de pixels:", + "Translate": "Traduzir", + "Translate Layer": "Traduzir Camada", + "Translate error, can not find dictionary:": "Erro de tradução, não foi possível encontrar dicionários:", + "Transparent:": "Transparente:", + "Trim": "Aparar", + "Trim Layers": "Aparar Camadas", + "Trim borders:": "Aparar Bordas:", + "Trim layer:": "Aparar camada:", + "Trim white color?": "Aparar cor branca?", + "Type:": "Tipo:", + "Türkçe": "Türkçe", + "Undo": "Desfazer", + "Unique colors:": "Cores únicas:", + "Up": "Acima", + "Update": "Atualizar", + "Update Brush Layer": "Atualizar Camada de Pincel", + "Update Pencil Layer": "Atualizar Camada de Lápis", + "Update guides": "Atualizar guias", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Use o atalho de teclado Ctrl + V para colar da área de transferência.", + "V Radius:": "Raio V.", + "V. Align:": "Alinhamento V.", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Versão:", + "Vertical": "Vertical", + "Vertical Alignment": "Alinhamento vertical", + "Vertical blur:": "Desfoque vertical:", + "Vertical:": "Vertical:", + "Vibrance": "Vibração", + "View": "Visualizar", + "Vignette": "Vinheta", + "ViliusL": "ViliusL", + "Vintage": "Vintage", + "Webcam": "Webcam", + "Webcam #": "Webcam #", + "Website:": "Website:", + "Weppy File Format": "Formato de arquivo Weppy", + "Width (%):": "Largura (%):", + "Width:": "Largura:", + "Windows Bitmap": "Bitmap do Windows", + "Word": "Palavra", + "Word + Letter": "Palavra + Letra", + "Wrap At:": "Embrulhar em:", + "Wrap:": "Embrulho:", + "Wrong dimensions": "Dimensões erradas", + "Wrong file type, must be image or json.": "Tipo de arquivo errado, deve ser um arquivo do tipo imagem ou json.", + "X end:": "X final:", + "X position:": "Posição X:", + "X start:": "X inicial:", + "X-Pro II": "X-Pro II", + "Y end:": "Y final:", + "Y position:": "Posição Y:", + "Y start:": "Y inicial:", + "You can also drag and drop items into browser.": "Você também pode arrastar e soltar itens no navegador.", + "Your browser does not support canvas or JavaScript is not enabled.": "Seu navegador não é compatível com o HTML CANVAS ou o JavaScript não está habilitado.", + "Your browser does not support this format.": "Seu navegador não suporta este formato.", + "Your search did not match any images.": "Sua pesquisa não corresponde a nenhuma imagem.", + "Zoom": "Zoom", + "Zoom Blur": "Desfoque de zoom", + "Zoom In": "Mais Zoom", + "Zoom Out": "Menos zoom", + "Zoom blur": "Desfoque de zoom", + "Zoom in": "Mais Zoom", + "Zoom out": "Menos zoom", + "Zoom:": "Zoom:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/ru.json b/paintplus/frontend/src/js/languages/ru.json new file mode 100644 index 0000000..25c572e --- /dev/null +++ b/paintplus/frontend/src/js/languages/ru.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Ошибка при удалении истории отмен. Это", + "About": "О проекте", + "Active": "Активный", + "Aden": "Aden", + "Advanced": "Продвинутый", + "All": "Все", + "Alpha": "Альфа", + "Alpha:": "Альфа:", + "Anonymous": "Анонимное", + "Anti aliasing": "Сглаживание", + "Application markup may have changed,": "Разметка приложения могла измениться,", + "Arial": "Arial", + "Arrow": "Стрелка", + "ArrowDown": "Стрелка вниз", + "ArrowLeft": "Стрелка влево", + "ArrowRight": "Стрелка вправо", + "ArrowUp": "Стрелка вверх", + "Author:": "Автор:", + "Auto Adjust Colors": "Автоматическая настройка цвета", + "Auto Kerning": "Автоматический кернинг", + "Average:": "В среднем:", + "Backspace": "Backspace", + "Base": "База", + "Basic": "Основа", + "Black and White": "Черный и Белый", + "Blue": "Синий", + "Blue channel:": "Синий канал:", + "Blueprint": "Чертеж", + "Blur Radius:": "Радиус Размытия:", + "Blur Tool": "Инструмент Размытия", + "Blur power:": "Сила размытия:", + "Borders": "Границы", + "Bottom": "Дно", + "Bottom to Top": "Снизу вверх", + "Bounds:": "Границы:", + "Box": "Коробка", + "Box Blur": "Размытие по рамке", + "Box blur": "Размытие по рамке", + "Brightness": "Яркость", + "Brightness:": "Яркость:", + "Bulge\/Pinch Tool": "Вдавливание\/вытяжение", + "Burn": "Выжигание", + "Can not animate 1 layer.": "Невозможно анимировать 1 слой.", + "Can not find previous layer.": "Не удается найти предыдущий слой.", + "Can not use this tool on current layer: image already takes all area.": "Невозможно использовать этот инструмент на текущем слое: изображение уже занимает всю область.", + "Cancel": "Отмена", + "Canvas Size": "Размер холста", + "Center": "Центр", + "Center x:": "Центр x:", + "Center y:": "Центр y:", + "Center:": "Центр:", + "Change Composition": "Изменить состав", + "Change Layer Details": "Изменить сведения о слое", + "Change Opacity": "Изменить непрозрачность", + "Channel:": "Источник:", + "Circle": "Круг", + "Clarendon": "Clarendon", + "Clear": "Очистить", + "Clear Selection": "Очистить Выбор", + "Clone Tool": "Инструмент клонирования", + "Clone count:": "Количество клонов:", + "Clone tool disabled for resized image. Please rasterize first.": "Инструмент клонирования отключен для изображения с измененным размером. Пожалуйста, сначала растрируйте.", + "Cloned edges": "Клонированные края", + "Close": "Закрывать", + "Color #": "Цвет #", + "Color Corrections": "Коррекция цвета", + "Color Palette": "Цветовая палитра", + "Color Zoom": "Усиление цвета", + "Color alpha value can not be zero.": "Значение цвета не может быть равно нулю.", + "Color to Alpha": "Цвет в прозрачность", + "Color zoom": "Усиление цвета", + "Color:": "Цвет:", + "Colors": "Цвета", + "Colors:": "Цвета:", + "Common Filters": "Обычные фильтры", + "Composition": "Состав", + "Composition:": "Состав:", + "Content Fill": "Заполнение содержимого", + "Contrast": "Контраст", + "Contrast:": "Контраст:", + "Convert layer to raster": "Растрировать слой", + "Convert to Raster": "Растрировать", + "Copy Selection": "Копировать выделение", + "Copy to Clipboard": "Скопировать в буфер", + "Courier": "Courier", + "Crop Tool": "Инструмент для Обрезки", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Обрезка повернутого слоя не поддерживается. Растрируйте его, чтобы продолжить.", + "Ctrl + C": "Ctrl + С", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+П", + "Ctrl+V": "Ctrl + V,", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "Выбранный", + "Current Color Preview": "Выбранный цвет", + "Custom": "Свой", + "Data URL": "URL данных", + "Data URL:": "URL данных:", + "Decrease": "Уменьшить", + "Decrease Color Depth": "Уменьшить глубину цвета", + "Degree:": "Градус:", + "Del": "Del", + "Delete": "Удалить", + "Delete Selection": "Удалить выделение", + "Denoise": "Шумоподавление", + "Desaturate Tool": "Инструмент обесцвечивания", + "Description:": "Описание:", + "Deutsch": "Deutsch", + "Differences": "Различия", + "Differences Down": "Разница вниз", + "Direction:": "Направление:", + "Dither": "Сгладить", + "Dithering:": "Сглаживание:", + "Dominant color:": "Доминирующий:", + "Dot Screen": "Точечный экран", + "Down": "Вниз", + "Duplicate": "Дублировать", + "Duplicate Layer": "Дублировать слой", + "Duplicate layer": "Дублировать слой", + "Dynamic": "Динамический", + "Edge": "Край", + "Edit": "Редактировать", + "Edit text...": "Редактировать текст...", + "Effect browser": "Подборка фильтров", + "Effects": "Фильтры", + "Effects browser": "Подборка фильтров", + "Email:": "Email:", + "Emboss": "Тиснение", + "Empty selection": "Пустой выбор", + "Empty selection or type not image.": "Пустой выбор или введите не изображение.", + "Enable autoresize:": "Автоматически увеличивать холст:", + "End": "Конец", + "English": "English", + "English (UK)": "английский (Великобритания)", + "Enrich": "Насытить", + "Enter": "Войти", + "Erase Tool": "Ластик", + "Erase on rotate object is disabled. Please rasterize first.": "Стирание при повороте объекта отключено. Пожалуйста, сначала растрируйте.", + "Error": "Ошибка", + "Error connecting to service.": "Ошибка подключения к сервису.", + "Error loading the list of fonts from Google.": "Ошибка загрузки списка шрифтов из Google.", + "Error registering service worker": "Ошибка регистрации сервис-воркера", + "Error: can not find filter:": "Ошибка: не удалось найти фильтр:", + "Error: can not find layer with id:": "Ошибка: не удается найти слой с идентификатором:", + "Error: missing details event target": "Ошибка: отсутствует цель события", + "Error: unknown layer type:": "Ошибка: неизвестный тип слоя:", + "Error: unsupported attribute type:": "Ошибка: неподдерживаемый тип атрибута:", + "Esc": "Esc", + "Escape": "ESC", + "Español": "Español", + "Expand edges": "Развернуть края", + "Exponent:": "Экспонент:", + "Export": "Экспорт", + "External": "Внешние инструменты", + "Factor:": "Фактор:", + "File": "Файл", + "File name:": "Имя файла:", + "File size:": "Размер файла:", + "Fill": "Заливка", + "Fill Tool": "Инструмент Заливки", + "Fit": "Вписать", + "Fit Window": "Вписать в окно", + "Fit window": "Вписать в окно", + "Flatten Image": "Свести изображение", + "Flip": "Отразить", + "FloydSteinberg-serpentine": "FloydSteinberg-serpentine", + "Font": "Шрифт", + "Français": "Français", + "Full HD, 1080p": "Full HD, 1080p", + "Full Screen": "Полноэкранный", + "Full layers data": "Данные полных слоев", + "Gap:": "Зазор:", + "Gaussian Blur": "Гауссовское Размытие", + "Gif delay:": "Задержка Gif:", + "Gingham": "Зонтик", + "GitHub:": "GitHub:", + "Gradient Radius:": "Радиус градиента:", + "Grains": "Зерна", + "Graphics Interchange Format": "Формат обмена графикой", + "Gray": "Серый", + "Grayscale": "Оттенки серого", + "Greek": "Греческий", + "Green": "Зеленый", + "Green channel:": "Зеленый канал:", + "Greyscale:": "Оттенки серого:", + "Grid": "Сетка", + "Grid on\/off": "Сетка вкл\/выкл", + "Guides": "Гайдлайны", + "Guides enabled.": "Гайдлайны включены", + "H Radius:": "H Радиус:", + "H. Align:": "H. Выравнивание:", + "Heatmap": "Тепловая карта", + "Height (%):": "Высота (%):", + "Height:": "Высота:", + "Help": "Помощь", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Скрыть", + "Histogram": "Гистограмма", + "Histogram:": "Гистограмма:", + "Home": "Главная", + "Horizontal": "Горизонтально", + "Horizontal Alignment": "Горизонтальное выравнивание", + "Horizontal blur:": "Горизонтальное размытие:", + "Horizontal:": "Горизонтальный:", + "Hue": "Оттенок", + "Hue Rotate": "Вращение оттенка", + "Hue:": "Оттенок:", + "Image": "Изображение", + "Image data with multi-layers. Can be opened using miniPaint -": "Данные изображения с несколькими слоями. Может быть открыт с помощью miniPaint -", + "Impact": "Влиять", + "In proportion:": "Сохранять пропорции:", + "Increase": "Увеличить", + "Information": "Информация", + "Inkwell": "Inkwell", + "Insert": "Вставить", + "Insert guides": "Добавить гайд", + "Insert new layer": "Новый слой", + "Instagram Filters": "Фильтры Instagram", + "Invalid Hex Code": "Неверный HEX код", + "Italiano": "Italiano", + "JPG\/JPEG Format": "JPG\/JPEG Формат", + "Kerning:": "Интервал:", + "Key-Points": "Ключевые точки", + "KeyU": "КлючU", + "Keyboard Shortcuts": "Горячие клавиши", + "Keyword:": "Ключевое слово:", + "Lanczos": "Lanczos", + "Landscape": "Альбомная", + "Language": "Язык", + "Last modified": "Последнее изменение", + "Layer": "Слой", + "Layer details": "Активный слой", + "Layer is empty.": "Слой пуст.", + "Layer is not compatible with resize": "Слой несовместим с изменением размера", + "Layer is vector, convert it to raster to apply this tool.": "Слой является векторным, преобразуйте его в растровый, чтобы применить этот инструмент.", + "Layers": "Слои", + "Layers:": "Слои:", + "Layout:": "Ориентация:", + "Left": "Слева", + "Left to Right": "Слева направо", + "Level:": "Уровень:", + "Levels:": "Уровни:", + "Lietuvių": "Lietuvių", + "Lo-fi": "Lo-fi", + "Luminance:": "Освещенность:", + "Luminosity": "Освещенность", + "Magic Eraser Tool": "Волшебный ластик", + "Merge Down": "Соединить вниз", + "Merge Layers": "Соединить слои", + "Merged": "Объединенное", + "Metrics": "Метрики", + "Middle": "Средний", + "Missing at least 1 size parameter.": "Отсутствует хотя бы 1 параметр размера.", + "Missing permissions to write to Clipboard.cc": "Отсутствуют разрешения на запись в Clipboard.cc", + "Mode:": "Режим:", + "Module function not found.": "Функция модуля не найдена.", + "Modules class not found:": "Класс модулей не найден:", + "Monospace": "Моноширинный", + "Mosaic": "Мозаика", + "Mouse:": "Мышь:", + "Move": "Переместить", + "Move Layer": "Переместить слой", + "Move layer down": "Опустить слой ниже", + "Move layer up": "Поднять слой выше", + "Name:": "Имя:", + "Negative": "Негатив", + "New": "Новый", + "New Bezier Layer": "Новый слой Безье", + "New Brush Layer": "Новый слой Кисти", + "New Ellipse Layer": "Новый слой Эллипса", + "New File": "Новый файл", + "New Gradient Layer": "Новый слой Градиента", + "New Layer": "Новый слой", + "New Line Layer": "Новый слой Линии", + "New Pencil Layer": "Новый слой Карандаша", + "New Polygon Layer": "Новый полигональный слой", + "New Rectangle Layer": "Новый слой Прямоугольника", + "New Text Layer": "Новый Текстовый слой", + "New file": "Новый файл", + "New from Selection": "Новое из выделения", + "New layer": "Новый слой", + "Next": "Следующий", + "Night Vision": "Ночное видение", + "None": "Ничего", + "Nothing is selected.": "Ничего не выбрано.", + "Offset X:": "Смещение X:", + "Offset Y:": "Смещение Y:", + "Oil": "Масло", + "Ok": "ОК", + "Online image editor.": "Онлайн редактор изображений.", + "Opacity": "Непрозрачность", + "Opacity:": "Прозрачность:", + "Open": "Открыть", + "Open Data URL": "Открыть URL-адрес", + "Open Directory": "Открыть каталог", + "Open File": "Открыть файл", + "Open File Data URL": "URL-адрес файла", + "Open File URL": "Открыть URL-адрес", + "Open File Webcam": "Изображение с веб-камеры", + "Open Image": "Открыть файл", + "Open JSON File": "Открыть Файл JSON", + "Open Test Template": "Шаблон открытого теста", + "Open URL": "Открыть URL", + "Open data URL": "Открыть URL-адрес", + "Open from Webcam": "Изображение с веб-камеры", + "Original Size": "Оригинальный размер", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Конвертировать изображение в SVG", + "PageDown": "Листать вниз", + "PageUp": "Листать вверх", + "Palette": "Палитра", + "Parameter #1:": "Параметр #1:", + "Parameter #2:": "Параметр #2:", + "Paste": "Вставить", + "Pencil": "Карандаш", + "Percentage:": "Процент:", + "Pixels:": "Пиксели:", + "Placeholder comment for color channels": "Комментарий-заполнитель для цветовых каналов", + "Placeholder comment for color picker": "Комментарий-заполнитель для палитры цветов", + "Placeholder comment for color swatches": "Комментарий-заполнитель для образцов цвета", + "Portable Network Graphics": "Портативная Сетевая Графика", + "Portrait": "Портретная", + "Português": "Português", + "Position:": "Позиция:", + "Power:": "Сила:", + "Preview": "Навигация", + "Previous": "Предыдущий", + "Previous layer must be image, convert it to raster to apply this tool.": "Предыдущий слой должен быть изображением, растрируйте его, чтобы применить этот инструмент.", + "Print": "Распечатать", + "Quality:": "Качество:", + "Quick Load": "Быстрое открытие", + "Quick Save": "Быстрое сохранение", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Удалить Фон Изображения", + "Radial": "Радиальный", + "Radial gradient": "Радиальный градиент", + "Radius:": "Радиус:", + "Range:": "Диапазон:", + "Red": "Красный", + "Red channel:": "Красный канал:", + "Redo": "Повторить", + "Remove all": "Удалить все", + "Rename": "Переименовать", + "Rename Layer": "Переименовать слой", + "Rendered with errors.": "Отрисовано с ошибками.", + "Rendering...": "Отрисовка ...", + "Replace Color": "Заменить цвет", + "Replace color": "Заменить цвет", + "Replacement:": "Замена:", + "Report Issues": "Сообщить о проблемах", + "Reset": "Сброс", + "Resize": "Изменить размер", + "Resize Boundary": "Изменить размер границы", + "Resize Layer": "Изменить размер слоя", + "Resize Layers": "Изменить размер слоев", + "Resize Text Layer": "Изменить размер текстового слоя", + "Resized as background": "Изменено в качестве фона", + "Resized:": "Изменён размер:", + "Resolution:": "Разрешение:", + "Restore Alpha": "Восстановить прозрачность", + "Right": "Вправо", + "Right angle:": "Прямой угол:", + "Right to Left": "Справа налево", + "Rotate": "Повернуть", + "Rotate Layer": "Повернуть слой", + "Rotate is not supported on this type of object. Convert to raster?": "Поворот на этом типе объекта не поддерживается. Растрировать?", + "Rotate left": "Повернуть влево", + "Rotate:": "Поворот:", + "Ruler": "Линейки", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Сжатие и сравнение изображений", + "Saturate": "Насытить", + "Saturation": "Насыщенность", + "Saturation:": "Насыщенность:", + "Save As": "Сохранить как", + "Save As Data URL": "Сохранить как base64", + "Save as": "Сохранить как", + "Save as type:": "Сохранить как тип:", + "Save layers:": "Сохранить слои:", + "Scaling up is not supported in Hermite, using Lanczos.": "В Hermite с использованием Lanczos масштабирование не поддерживается.", + "Scroll down": "Прокрутить вниз", + "Scroll up": "Прокрутка вверх", + "Search": "Поиск", + "Search Images": "Поиск изображений", + "Search for Font": "Поиск шрифта", + "Search:": "Поиск:", + "Select All": "Выбрать все", + "Select Text Layer": "Выбрать текстовый слой", + "Select object tool": "Выбор объекта", + "Selected": "Выбранный", + "Selection Tool": "Инструмент выделения", + "Sensitivity:": "Чувствительность:", + "Separated": "Отдельно", + "Separated (original types)": "Отдельно (оригинальный формат)", + "Sepia": "Сепия", + "Set Image Size": "Установить размер изображения", + "Settings": "Настройки", + "Shadow": "Тень", + "Shapes": "Фигуры", + "Shapes (H)": "Фигуры (H)", + "Sharpen": "Резкость", + "Sharpen Tool": "Инструмент резкости", + "Sharpen:": "Повысить четкость:", + "Shift + S": "Шифт + С", + "Shortcut Key:": "Быстрая клавиша:", + "Show": "Показывать", + "Show \/ Hide": "Показать \/ Спрятать", + "Show file size:": "Считать размер:", + "Simple": "Простой", + "Size is too big, max": "Размер слишком большой, максимум", + "Size:": "Размер:", + "Skip - layer must be image.": "Пропуск - слой должен быть изображением.", + "Solarize": "Высветлить", + "Sorry, cold not load getUserMedia() data:": "К сожалению, не удалось загрузить данные getUserMedia():", + "Sorry, image could not be loaded.": "К сожалению, изображение не может быть загружено.", + "Sorry, image could not be loaded. Try copy image and paste it.": "К сожалению, изображение не может быть загружено. Попробуйте скопировать изображение и вставьте его.", + "Sorry, image is too big, max 5 MB.": "К сожалению, изображение слишком большое, максимум 5 МБ.", + "Source coordinates saved.": "Исходные координаты сохранены.", + "Source is empty, right click on image or use long press to save source position.": "Источник пуст, щелкните изображение правой кнопкой мыши или нажмите и удерживайте, чтобы сохранить исходное положение.", + "Sprites": "Спрайты", + "Square": "Квадрат", + "Stream:": "Поток:", + "Strength:": "Прочность:", + "Strict": "Строго", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - Сжатие PNG и JPEG", + "Tab": "Вкладка", + "Tag Image File Format": "Формат файла изображения тега", + "Tahoma": "Tahoma", + "Target:": "Цель:", + "The quick brown fox jumps over the lazy dog.": "Быстрая коричневая лиса прыгает через ленивую собаку.", + "There": "Там", + "There are no layers behind.": "Позади нет слоев.", + "There is only 1 layer.": "Есть только 1 слой.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Слой должен содержать изображение, растрируйте его, чтобы применить этот инструмент.", + "Tilt Shift": "Tilt Shift", + "Times New Roman": "Times New Roman", + "Toaster": "Toaster", + "Toggle": " ", + "Toggle Color Channels": "Цветовые каналы", + "Toggle Color Picker": "Цветовая палитра", + "Toggle Menu": "Меню", + "Toggle Swatches": "Коллекция цветов", + "Tools": "Инструменты", + "Top": "Вверх", + "Top to Bottom": "Сверху вниз", + "Total pixels:": "Всего пикселей:", + "Translate": "Сдвинуть", + "Translate Layer": "Сдвинуть слой", + "Translate error, can not find dictionary:": "Ошибка перевода, не удалось найти словарь:", + "Transparent:": "Прозрачность:", + "Trim": "Обрезать", + "Trim Layers": "Обрезать слои", + "Trim borders:": "Обрезать границы:", + "Trim layer:": "Обрезной слой:", + "Trim white color?": "Обрезать белый цвет?", + "Type:": "Тип:", + "Türkçe": "Türkçe", + "Undo": "Отменить", + "Unique colors:": "Уникальные цвета:", + "Up": "Вверх", + "Update": "Обновить", + "Update Brush Layer": "Обновить слой Кисти", + "Update Pencil Layer": "Обновить слой карандаша", + "Update guides": "Обновить руководства", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Используйте комбинацию клавиш Ctrl + V для вставки из буфера обмена.", + "V Radius:": "В радиус:", + "V. Align:": "В. Выровнять:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Версия:", + "Vertical": "Вертикально", + "Vertical Alignment": "Вертикальное Выравнивание", + "Vertical blur:": "Вертикальное размытие:", + "Vertical:": "Вертикальное:", + "Vibrance": "Вибрация", + "View": "Вид", + "Vignette": "Виньетка", + "ViliusL": "ViliusL", + "Vintage": "Винтаж", + "Webcam": "Веб-камера", + "Webcam #": "Веб-камера #", + "Website:": "Веб-сайт:", + "Weppy File Format": "Формат файла Weppy", + "Width (%):": "Ширина (%):", + "Width:": "Ширина:", + "Windows Bitmap": "Растровое изображение Windows", + "Word": "Слово", + "Word + Letter": "Слово + Буква", + "Wrap At:": "Обернуть в:", + "Wrap:": "Обвернуть:", + "Wrong dimensions": "Неправильные размеры", + "Wrong file type, must be image or json.": "Неверный тип файла, тип файла должен быть изображением или json.", + "X end:": "X конец:", + "X position:": "X позиция:", + "X start:": "X начало:", + "X-Pro II": "X-Pro II", + "Y end:": "Y конец:", + "Y position:": "Y позиция:", + "Y start:": "Y начало:", + "You can also drag and drop items into browser.": "Вы также можете перетаскивать элементы в браузер.", + "Your browser does not support canvas or JavaScript is not enabled.": "Ваш браузер не поддерживает холст или JavaScript не включен.", + "Your browser does not support this format.": "Ваш браузер не поддерживает этот формат.", + "Your search did not match any images.": "Ваш поиск не соответствовал изображениям.", + "Zoom": "Приблизить", + "Zoom Blur": "Размытие Приближения", + "Zoom In": "Приблизить", + "Zoom Out": "Отдалить", + "Zoom blur": "Масштабирование", + "Zoom in": "Приблизить", + "Zoom out": "Отдалить", + "Zoom:": "Приблизить:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/tr.json b/paintplus/frontend/src/js/languages/tr.json new file mode 100644 index 0000000..9231da5 --- /dev/null +++ b/paintplus/frontend/src/js/languages/tr.json @@ -0,0 +1,513 @@ +{ + "A problem occurred while removing undo history. It": "Geri alma geçmişini kaldırırken bir sorun oluştu. O", + "About": "hakkında", + "Active": "Aktif", + "Aden": "Aden", + "Advanced": "ileri", + "All": "Herşey", + "Alpha": "Alfa", + "Alpha:": "Alfa:", + "Anonymous": "Anonim", + "Anti aliasing": "Örtüşme önleme", + "Application markup may have changed,": "Uygulama işaretlemesi değişmiş olabilir,", + "Arial": "Arial", + "Arrow": "Ok", + "ArrowDown": "Aşağı ok", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + "ArrowUp": "Yukarı ok", + "Author:": "Yazar:", + "Auto Adjust Colors": "Renkleri otomatik ayarla", + "Auto Kerning": "Otomatik Karakter Aralığı", + "Average:": "Ortalama:", + "Backspace": "Geri tuşu", + "Base": "baz", + "Basic": "Temel", + "Black and White": "Siyah ve beyaz", + "Blue": "Mavi", + "Blue channel:": "Mavi kanal:", + "Blueprint": "Taslak", + "Blur Radius:": "Bulanıklaştırma Yarıçapı:", + "Blur Tool": "Bulanıklık aracı", + "Blur power:": "Blur gücü:", + "Borders": "Sınırlar", + "Bottom": "Alt", + "Bottom to Top": "Alttan Üste", + "Bounds:": "Sınırlar:", + "Box": "Kutu", + "Box Blur": "Kutu bulanıklığı", + "Box blur": "Kutu bulanıklığı", + "Brightness": "Parlaklık", + "Brightness:": "Parlaklık:", + "Bulge\/Pinch Tool": "Bulge \/ Kıstırma Aracı", + "Burn": "Yanmak", + "Can not animate 1 layer.": "1 katmana canlandırma yapılamıyor.", + "Can not find previous layer.": "Önceki katmanı bulamıyorum.", + "Can not use this tool on current layer: image already takes all area.": "Bu araç geçerli katmanda kullanılamıyor: görüntü zaten tüm alanı kaplıyor.", + "Cancel": "İptal etmek", + "Canvas Size": "Tuval Boyutu", + "Center": "merkez", + "Center x:": "Merkez x:", + "Center y:": "Merkez y:", + "Center:": "merkez:", + "Change Composition": "Kompozisyonu Değiştir", + "Change Layer Details": "Katman Ayrıntılarını Değiştir", + "Change Opacity": "Opaklığı Değiştir", + "Channel:": "Kanal:", + "Circle": "Daire", + "Clarendon": "Clarendon", + "Clear": "Açık", + "Clear Selection": "Seçimi Temizle", + "Clone Tool": "Klonlama Aracı", + "Clone count:": "Klon sayısı:", + "Clone tool disabled for resized image. Please rasterize first.": "Yeniden boyutlandırılan resim için klonlama aracı devre dışı bırakıldı. Lütfen önce rasterleştirin.", + "Cloned edges": "Klonlanmış kenarlar", + "Close": "Kapalı", + "Color #": "Renk #", + "Color Corrections": "Renk düzeltmeleri", + "Color Palette": "Renk paleti", + "Color Zoom": "Renkli Zoom", + "Color alpha value can not be zero.": "Renkli alfa değeri sıfır olamaz.", + "Color to Alpha": "Alfanın renkleri", + "Color zoom": "Renkli yakınlaştırma", + "Color:": "Renk:", + "Colors": "Renkler", + "Colors:": "Renkler:", + "Common Filters": "Ortak Filtreler", + "Composition": "bileştirme, kompozisyon", + "Composition:": "Bileştirme, kompozisyon:", + "Content Fill": "İçerik doldurma", + "Contrast": "Kontrast", + "Contrast:": "Kontrast:", + "Convert layer to raster": "Katmanı raster'a dönüştür", + "Convert to Raster": "Rastera dönüştürün", + "Copy Selection": "Seçimi kopyala", + "Copy to Clipboard": "Panoya kopyala", + "Courier": "Kurye", + "Crop Tool": "Kırpma aracı", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "Döndürülmüş katmanda kırpma desteklenmez. Devam etmek için raster'e dönüştürün.", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl + A", + "Ctrl+C": "Ctrl + C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl + V", + "Ctrl+Y": "Ctrl + Y", + "Ctrl+Z": "Ctrl + Z", + "Current": "şimdiki", + "Current Color Preview": "Mevcut Renk Önizlemesi", + "Custom": "görenek", + "Data URL": "Veri URL'si", + "Data URL:": "Veri URL'si:", + "Decrease": "Azaltmak", + "Decrease Color Depth": "Renk derinliğini azalt", + "Degree:": "Derece:", + "Del": "Del", + "Delete": "silmek", + "Delete Selection": "Seçimi sil", + "Denoise": "Denoise", + "Desaturate Tool": "Doygunluğu Azaltma Aracı", + "Description:": "Açıklama:", + "Deutsch": "Deutsch", + "Differences": "farklılıklar", + "Differences Down": "Farklar Aşağı", + "Direction:": "Yön:", + "Dither": "titreme", + "Dithering:": "taklidi:", + "Dominant color:": "Hakim renk:", + "Dot Screen": "Nokta Ekranı", + "Down": "Aşağı", + "Duplicate": "Çift", + "Duplicate Layer": "Yinelenen katman", + "Duplicate layer": "Yinelenen katman", + "Dynamic": "Dinamik", + "Edge": "kenar", + "Edit": "Düzenle", + "Edit text...": "Metni düzenle...", + "Effect browser": "Efekt tarayıcısı", + "Effects": "Etkileri", + "Effects browser": "Efekt tarayıcısı", + "Email:": "E-posta:", + "Emboss": "kabartma yapmak", + "Empty selection": "Boş seçim", + "Empty selection or type not image.": "Boş seçim veya resim değil yazın.", + "Enable autoresize:": "Otomatik yeniden boyutlandırmayı etkinleştir:", + "End": "Son", + "English": "ingilizce", + "English (UK)": "İngilizce (İngiltere)", + "Enrich": "Zenginleştirmek", + "Enter": "Giriş", + "Erase Tool": "Silme Aracı", + "Erase on rotate object is disabled. Please rasterize first.": "Nesneyi döndürürken silme devre dışı bırakılır. Lütfen önce rasterleştirin.", + "Error": "Hata", + "Error connecting to service.": "Hizmete bağlanırken hata oluştu.", + "Error loading the list of fonts from Google.": "Google'dan yazı tipi listesi yüklenirken hata oluştu.", + "Error registering service worker": "Hizmet çalışanı kaydedilirken hata oluştu", + "Error: can not find filter:": "Hata: filtre bulunamıyor:", + "Error: can not find layer with id:": "Hata: kimliğine sahip katman bulunamıyor:", + "Error: missing details event target": "Hata: eksik olan ayrıntılar etkinlik hedefi", + "Error: unknown layer type:": "Hata: bilinmeyen katman türü:", + "Error: unsupported attribute type:": "Hata: desteklenmeyen özellik türü:", + "Esc": "ESC", + "Escape": "Kaçış", + "Español": "Español", + "Expand edges": "Kenarları genişlet", + "Exponent:": "Üs:", + "Export": "İhracat", + "External": "Harici", + "Factor:": "Faktör:", + "File": "Dosya", + "File name:": "Dosya adı:", + "File size:": "Dosya boyutu:", + "Fill": "doldurmak", + "Fill Tool": "Doldurma Aracı", + "Fit": "Fit", + "Fit Window": "Pencereye sığdır", + "Fit window": "Pencereyi sığdır", + "Flatten Image": "Resmi Düzleştir", + "Flip": "fiske", + "FloydSteinberg-serpentine": "FloydSteinberg-serpantin", + "Font": "Yazı tipi", + "Français": "Français", + "Full HD, 1080p": "Tam HD, 1080p", + "Full Screen": "Tam ekran", + "Full layers data": "Tam katman verileri", + "Gap:": "boşluk:", + "Gaussian Blur": "Gauss Bulanıklığı", + "Gif delay:": "Gif gecikmesi:", + "Gingham": "Şemsiye", + "GitHub:": "GitHub:", + "Gradient Radius:": "Gradyan Yarıçapı:", + "Grains": "Taneler", + "Graphics Interchange Format": "Grafik Değişim Biçimi", + "Gray": "Gri", + "Grayscale": "Gri tonlama", + "Greek": "Yunan", + "Green": "Yeşil", + "Green channel:": "Yeşil kanal:", + "Greyscale:": "Gri tonlama:", + "Grid": "Kafes", + "Grid on\/off": "Izgara açık \/ kapalı", + "Guides": "Kılavuzlar", + "Guides enabled.": "Kılavuzlar etkinleştirildi.", + "H Radius:": "H Radius:", + "H. Align:": "H. Hizala:", + "Heatmap": "Sıcaklık haritası", + "Height (%):": "Yükseklik (%):", + "Height:": "Yükseklik:", + "Help": "yardım et", + "Helvetica": "Helvetica", + "Hermite": "Hermite", + "Hex": "Hex", + "Hide": "Saklamak", + "Histogram": "Histogram", + "Histogram:": "Histogram:", + "Home": "Ev", + "Horizontal": "Yatay", + "Horizontal Alignment": "Yatay hizalama", + "Horizontal blur:": "Yatay bulanıklık:", + "Horizontal:": "Yatay:", + "Hue": "Ton", + "Hue Rotate": "Ton Döndür", + "Hue:": "Ton:", + "Image": "görüntü", + "Image data with multi-layers. Can be opened using miniPaint -": "Çok katmanlı görüntü verileri. MiniPaint ile açılabilir -", + "Impact": "darbe", + "In proportion:": "Orantılı olarak:", + "Increase": "Artırmak", + "Information": "Bilgi", + "Inkwell": "Inkwell", + "Insert": "Sokmak", + "Insert guides": "Kılavuzları ekle", + "Insert new layer": "Yeni katman ekle", + "Instagram Filters": "Instagram Filtreleri", + "Invalid Hex Code": "Geçersiz Hex Kodu", + "Italiano": "Italiano", + "JPG\/JPEG Format": "JPG \/ JPEG Biçimi", + "Kerning:": "Karakter aralığı:", + "Key-Points": "Anahtar noktaları", + "KeyU": "KeyU", + "Keyboard Shortcuts": "Klavye kısayolları", + "Keyword:": "Anahtar kelime:", + "Lanczos": "Lanczos", + "Landscape": "Manzara", + "Language": "Dil", + "Last modified": "Son düzenleme", + "Layer": "Katman", + "Layer details": "Katman ayrıntıları", + "Layer is empty.": "Katman boş.", + "Layer is not compatible with resize": "Katman yeniden boyutlandırmayla uyumlu değil", + "Layer is vector, convert it to raster to apply this tool.": "Katman vektördür, bu aracı uygulamak için onu raster'e dönüştürün.", + "Layers": "Katmanlar", + "Layers:": "Katmanlar:", + "Layout:": "Düzen:", + "Left": "Ayrıldı", + "Left to Right": "Soldan sağa", + "Level:": "Seviye:", + "Levels:": "Seviyeleri:", + "Lietuvių": "Litvanya", + "Lo-fi": "Lo-fi", + "Luminance:": "Parlaklık:", + "Luminosity": "Parlaklık", + "Magic Eraser Tool": "Sihirli Silgi Aracı", + "Merge Down": "Aşağı Birleştir", + "Merge Layers": "Katmanları birleştirmek", + "Merged": "Birleştirilmiş", + "Metrics": "Metrikler", + "Middle": "Orta", + "Missing at least 1 size parameter.": "En az 1 boyut parametresi eksik.", + "Missing permissions to write to Clipboard.cc": "Clipboard.cc'ye yazma izinleri eksik", + "Mode:": "Mod:", + "Module function not found.": "Modül işlevi bulunamadı.", + "Modules class not found:": "Modüller sınıf bulunamadı:", + "Monospace": "Tek aralıklı", + "Mosaic": "Mozaik", + "Mouse:": "Fare:", + "Move": "Hareket", + "Move Layer": "Katmanı Taşı", + "Move layer down": "Katmanı aşağı taşı", + "Move layer up": "Katmanı yukarı taşı", + "Name:": "Adı:", + "Negative": "Negatif", + "New": "Yeni", + "New Bezier Layer": "Yeni Bezier Katmanı", + "New Brush Layer": "Yeni Fırça Katmanı", + "New Ellipse Layer": "Yeni Elips Katmanı", + "New File": "Yeni dosya", + "New Gradient Layer": "Yeni Gradyan Katmanı", + "New Layer": "Yeni tabaka", + "New Line Layer": "Yeni Çizgi Katmanı", + "New Pencil Layer": "Yeni Kalem Katmanı", + "New Polygon Layer": "Yeni Çokgen Katmanı", + "New Rectangle Layer": "Yeni Dikdörtgen Katman", + "New Text Layer": "Yeni Metin Katmanı", + "New file": "Yeni dosya", + "New from Selection": "Seçimden yeni", + "New layer": "Yeni katman", + "Next": "Sonraki", + "Night Vision": "Gece görüşü", + "None": "Yok", + "Nothing is selected.": "Hiçbir şey seçilmedi.", + "Offset X:": "Ofset X:", + "Offset Y:": "Ofset Y:", + "Oil": "Sıvı yağ", + "Ok": "Tamam", + "Online image editor.": "Çevrimiçi görüntü düzenleyici.", + "Opacity": "opaklık", + "Opacity:": "Saydamlık:", + "Open": "Açık", + "Open Data URL": "Açık Veri URL'si", + "Open Directory": "Açık sözlük", + "Open File": "Açık dosya", + "Open File Data URL": "Dosya Verileri URL'sini Aç", + "Open File URL": "Dosya URL'sini Aç", + "Open File Webcam": "Dosya Web Kamerasını Aç", + "Open Image": "Resmi Aç", + "Open JSON File": "JSON Dosyasını Aç", + "Open Test Template": "Test Şablonunu Aç", + "Open URL": "Link aç", + "Open data URL": "Açık veri URL'si", + "Open from Webcam": "Web Kamerasından Aç", + "Original Size": "Orijinal boyut", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - Resmi SVG'ye Dönüştür", + "PageDown": "PageDown", + "PageUp": "Sayfa yukarı", + "Palette": "palet", + "Parameter #1:": "Parametre # 1:", + "Parameter #2:": "Parametre # 2:", + "Paste": "Yapıştırmak", + "Pencil": "Kalem", + "Percentage:": "Yüzde:", + "Pixels:": "Piksel:", + "Placeholder comment for color channels": "Renk kanalları için yer tutucu yorumu", + "Placeholder comment for color picker": "Renk seçici için yer tutucu yorumu", + "Placeholder comment for color swatches": "Renk örnekleri için yer tutucu yorumu", + "Portable Network Graphics": "taşınabilir Ağ Grafikleri", + "Portrait": "Vesika", + "Português": "Português", + "Position:": "Konum:", + "Power:": "Güç:", + "Preview": "Ön izleme", + "Previous": "Önceki", + "Previous layer must be image, convert it to raster to apply this tool.": "Önceki katman resim olmalıdır, bu aracı uygulamak için raster haline getirin.", + "Print": "baskı", + "Quality:": "Kalite:", + "Quick Load": "Hızlı yükleme", + "Quick Save": "Hızlı kaydet", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - Resim Arka Planını Kaldır", + "Radial": "Radyal", + "Radial gradient": "Radyal degrade", + "Radius:": "radius:", + "Range:": "aralık:", + "Red": "Kırmızı", + "Red channel:": "Kırmızı kanal:", + "Redo": "Yeniden yap", + "Remove all": "Hepsini kaldır", + "Rename": "Adını değiştirmek", + "Rename Layer": "Katmanı Yeniden Adlandır", + "Rendered with errors.": "Hatalarla oluşturuldu.", + "Rendering...": "Oluşturuluyor ...", + "Replace Color": "Renk Değiştir", + "Replace color": "Rengi değiştir", + "Replacement:": "Değiştirme:", + "Report Issues": "Sorunları bildir", + "Reset": "Reset", + "Resize": "yeniden boyutlandırma", + "Resize Boundary": "Sınırı Yeniden Boyutlandır", + "Resize Layer": "Katmanı Yeniden Boyutlandır", + "Resize Layers": "Katmanları Yeniden Boyutlandır", + "Resize Text Layer": "Metin Katmanını Yeniden Boyutlandır", + "Resized as background": "Arka plan olarak yeniden boyutlandırıldı", + "Resized:": "Yeniden boyutlandırıldı:", + "Resolution:": "Çözüm:", + "Restore Alpha": "Alfa geri yükle", + "Right": "Sağ", + "Right angle:": "Doğru açı:", + "Right to Left": "Sağdan sola", + "Rotate": "Döndürme", + "Rotate Layer": "Katmanı Döndür", + "Rotate is not supported on this type of object. Convert to raster?": "Döndürme, bu tür nesne üzerinde desteklenmiyor. Rastere dönüştürün?", + "Rotate left": "Sola dön", + "Rotate:": "Dönüşümlü:", + "Ruler": "Cetvel", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - Resimleri Sıkıştır ve Karşılaştır", + "Saturate": "bombalamak", + "Saturation": "Doyma", + "Saturation:": "Doyma:", + "Save As": "Farklı kaydet", + "Save As Data URL": "Veri URL'si olarak kaydet", + "Save as": "Farklı kaydet", + "Save as type:": "Türünü kaydet:", + "Save layers:": "Katmanları kaydet:", + "Scaling up is not supported in Hermite, using Lanczos.": "Lanczos kullanılarak Hermite'de ölçek büyütme desteklenmez.", + "Scroll down": "Aşağı kaydır", + "Scroll up": "Yukarı kaydırmak", + "Search": "Arama", + "Search Images": "Resimleri ara", + "Search for Font": "Yazı Tipi Ara", + "Search:": "Aramak:", + "Select All": "Hepsini seç", + "Select Text Layer": "Metin Katmanı Seçin", + "Select object tool": "Nesne aracını seçin", + "Selected": "seçilmiş", + "Selection Tool": "Seçim aracı", + "Sensitivity:": "Duyarlılık:", + "Separated": "Ayrılmış", + "Separated (original types)": "Ayrılmış (orijinal türler)", + "Sepia": "Sepya", + "Set Image Size": "Görüntü Boyutunu Ayarla", + "Settings": "Ayarlar", + "Shadow": "Gölge", + "Shapes": "Şekiller", + "Shapes (H)": "Şekiller (H)", + "Sharpen": "keskinleştirmek", + "Sharpen Tool": "Aleti keskinleştir", + "Sharpen:": "keskinleştir:", + "Shift + S": "Üst Karakter + S", + "Shortcut Key:": "Kısayol tuşu:", + "Show": "Göstermek", + "Show \/ Hide": "Göster \/ gizle", + "Show file size:": "Dosya boyutunu göster:", + "Simple": "Basit", + "Size is too big, max": "Boyut çok büyük, maks.", + "Size:": "Boyut:", + "Skip - layer must be image.": "Atlama - katman resim olmalıdır.", + "Solarize": "güneşte bırakmak", + "Sorry, cold not load getUserMedia() data:": "Maalesef getUserMedia () verilerini yükleme değil:", + "Sorry, image could not be loaded.": "Maalesef resim yüklenemedi.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Üzgünüz, resim yüklenemedi. Resmi kopyala ve yapıştırmayı deneyin.", + "Sorry, image is too big, max 5 MB.": "Maalesef, resim çok büyük, maksimum 5 MB.", + "Source coordinates saved.": "Kaynak koordinatlar kaydedildi.", + "Source is empty, right click on image or use long press to save source position.": "Kaynak boş, görüntüye sağ tıklayın veya kaynak konumunu kaydetmek için uzun basın.", + "Sprites": "Spritelar", + "Square": "Kare", + "Stream:": "Akış:", + "Strength:": "Sertlik:", + "Strict": "sıkı", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - PNG ve JPEG'i sıkıştır", + "Tab": "Sekme", + "Tag Image File Format": "Etiket Görüntüsü Dosya Formatı", + "Tahoma": "Tahoma", + "Target:": "Hedef:", + "The quick brown fox jumps over the lazy dog.": "Hızlı kahverengi tilki tembel köpeğin üzerinden atlıyor.", + "There": "Orada", + "There are no layers behind.": "Arkada hiçbir katman yok.", + "There is only 1 layer.": "Sadece bir tabaka var.", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Katman görüntü olmalı, onu uygulamak için rastgele dönüştürmelidir.", + "Tilt Shift": "Eğim Kaydırma", + "Times New Roman": "Times New Roman", + "Toaster": "Tost makinası", + "Toggle": "geçiş", + "Toggle Color Channels": "Renk Kanallarını Değiştir", + "Toggle Color Picker": "Renk Seçiciyi Değiştir", + "Toggle Menu": "Menüyü Değiştir", + "Toggle Swatches": "Renk Örneklerini Aç \/ Kapat", + "Tools": "Araçlar", + "Top": "Üst", + "Top to Bottom": "Yukarıdan Aşağıya", + "Total pixels:": "Toplam piksel:", + "Translate": "Çevirmek", + "Translate Layer": "Katmanı Çevir", + "Translate error, can not find dictionary:": "Çeviri hatası, sözlük bulunamadı:", + "Transparent:": "Şeffaf:", + "Trim": "düzeltmek", + "Trim Layers": "Katmanları Kırp", + "Trim borders:": "Kenarlıkları kırp:", + "Trim layer:": "Döşeme tabakası:", + "Trim white color?": "Beyaz rengini keser misin?", + "Type:": "Tip:", + "Türkçe": "Türkçe", + "Undo": "Geri alma", + "Unique colors:": "Eşsiz renkler:", + "Up": "yukarı", + "Update": "Güncelleme", + "Update Brush Layer": "Fırça Katmanını Güncelle", + "Update Pencil Layer": "Kalem Katmanını Güncelle", + "Update guides": "Kılavuzları güncelleyin", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "Pano'dan yapıştırmak için Ctrl + V klavye kısayolunu kullanın.", + "V Radius:": "V Yarıçapı:", + "V. Align:": "V. Hizala:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "Versiyon:", + "Vertical": "Dikey", + "Vertical Alignment": "Dikey hizalama", + "Vertical blur:": "Dikey bulanıklık:", + "Vertical:": "Dikey:", + "Vibrance": "Titreşim", + "View": "Görüş", + "Vignette": "skeç", + "ViliusL": "ViliusL", + "Vintage": "bağbozumu", + "Webcam": "Web kamerası", + "Webcam #": "Web kamerası #", + "Website:": "Web sitesi:", + "Weppy File Format": "Weppy Dosya Biçimi", + "Width (%):": "Genişlik (%):", + "Width:": "Genişlik:", + "Windows Bitmap": "Windows Bit Eşlem", + "Word": "Kelime", + "Word + Letter": "Kelime + Harf", + "Wrap At:": "Şuraya Sar:", + "Wrap:": "Paketlemek:", + "Wrong dimensions": "Yanlış boyutlar", + "Wrong file type, must be image or json.": "Yanlış dosya türü, resim veya json olmalı.", + "X end:": "X sonu:", + "X position:": "X konumu:", + "X start:": "X start:", + "X-Pro II": "X-Pro II", + "Y end:": "Sonum:", + "Y position:": "Y pozisyonu:", + "Y start:": "Y başlatın:", + "You can also drag and drop items into browser.": "Ayrıca öğeleri tarayıcıya sürükleyip bırakabilirsiniz.", + "Your browser does not support canvas or JavaScript is not enabled.": "Tarayıcınız tuvali desteklemiyor veya JavaScript etkin değil.", + "Your browser does not support this format.": "Tarayıcınız bu biçimi desteklemiyor.", + "Your search did not match any images.": "Aramanız herhangi bir resimle eşleşmedi.", + "Zoom": "yakınlaştırma", + "Zoom Blur": "Zum Bulanıklığı", + "Zoom In": "Yakınlaştır", + "Zoom Out": "Uzaklaştır", + "Zoom blur": "Yakınlaştırma bulanıklığı", + "Zoom in": "Yakınlaştır", + "Zoom out": "Uzaklaştır", + "Zoom:": "zum:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/uk.json b/paintplus/frontend/src/js/languages/uk.json new file mode 100644 index 0000000..65009ec --- /dev/null +++ b/paintplus/frontend/src/js/languages/uk.json @@ -0,0 +1,507 @@ +{ + "24-Points star": "", + "A problem occurred while removing undo history. It": "Sorry, a problem occurred while removing the undo history.", + "About": "", + "Active": "", + "Add Borders": "", + "Aden": "", + "Advanced": "", + "All": "", + "Alpha": "", + "Alpha:": "", + "Anonymous": "", + "Anti aliasing": "", + "Application markup may have changed,": "The application markup may have changed", + "Arial": "", + "Arrow": "", + "ArrowDown": "", + "ArrowLeft": "", + "ArrowRight": "", + "ArrowUp": "", + "Author:": "", + "Auto Adjust Colors": "Auto Adjust the Colours", + "Auto Kerning": "", + "Average:": "", + "Backspace": "", + "Base": "", + "Basic": "", + "Black and White": "", + "Blue": "", + "Blue channel:": "", + "Blueprint": "", + "Blur Radius:": "", + "Blur Tool": "", + "Blur power:": "", + "Borders": "", + "Bottom": "", + "Bottom to Top": "", + "Bounds:": "", + "Box": "", + "Box Blur": "", + "Box blur": "", + "Brightness": "", + "Brightness:": "", + "Bulge\/Pinch Tool": "", + "Burn": "", + "Can not animate 1 layer.": "Sorry, you can not animate just 1 layer, you need at least 2 layers.", + "Can not find previous layer.": "Sorry, I can not find the previous layer.", + "Cancel": "", + "Canvas Size": "", + "Canvas size": "", + "Center": "Centre", + "Center x:": "Centre x:", + "Center y:": "Centre y:", + "Center:": "Centre:", + "Change Composition": "", + "Change Layer Details": "", + "Change Opacity": "", + "Channel:": "", + "Circle": "", + "Clarendon": "", + "Clear": "", + "Clear Selection": "", + "Clone Tool": "", + "Clone count:": "", + "Clone tool disabled for resized image. Sorry.": "Sorry, the clone tool is disabled for use on a resized asset (image). Undo the resize, clone the asset (image) then resize it again.", + "Cloned edges": "", + "Color #": "Colour #", + "Color Corrections": "Colour Corrections", + "Color Palette": "Colour Palette", + "Color Zoom": "Colour Zoom", + "Color alpha value can not be zero.": "The colour alpha value can not be zero. Please change it.", + "Color to Alpha": "Colour to Alpha", + "Color zoom": "Colour zoom", + "Color:": "Colour:", + "Colors": "Colours", + "Colors:": "Colours:", + "Common Filters": "", + "Composition": "", + "Composition:": "", + "Content Fill": "", + "Contrast": "", + "Contrast:": "", + "Convert to Raster": "Convert to a Raster", + "Copy Selection": "", + "Copy to Clipboard": "", + "Copy:": "", + "Courier": "", + "Crop Tool": "", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "You can not crop a rotated layer. Please convert it to a raster to continue.", + "Ctrl+A": "", + "Ctrl+C": "", + "Ctrl+V": "", + "Ctrl+Y": "", + "Ctrl+Z": "", + "Ctrl-P": "", + "Current": "", + "Current Color Preview": "Current Colour Preview", + "Custom": "", + "Data URL": "", + "Data URL:": "", + "Decrease": "", + "Decrease Color Depth": "Decrease Colour Depth", + "Degree:": "", + "Del": "", + "Delete": "", + "Delete Selection": "", + "Denoise": "", + "Desaturate Tool": "", + "Description:": "", + "Deutsch": "", + "Differences": "", + "Differences Down": "", + "Direction:": "", + "Dither": "", + "Dithering:": "", + "Dominant color:": "Dominant colour:", + "Dot Screen": "", + "Down": "", + "Duplicate": "", + "Duplicate Layer": "", + "Dynamic": "", + "Edge": "", + "Edit": "", + "Edit text...": "", + "Effect browser": "", + "Effects": "", + "Effects browser": "", + "Email:": "", + "Emboss": "", + "Empty selection": "", + "Empty selection or type not image.": "You have selected nothing or the asset is not an image.", + "Enable guides:": "", + "Enable snap:": "", + "End": "", + "English": "English UK", + "Enrich": "", + "Enter": "", + "Erase Tool": "", + "Erase on rotate object is disabled. Sorry.": "Sorry you can not erase on rotated asset (object). Remove the rotation then you can delete it.", + "Error": "", + "Error connecting to service.": "", + "Error loading the list of fonts from Google.": "There is an error loading the list of fonts from Google. Please report this.", + "Error registering service worker": "", + "Error: can not find filter:": "", + "Error: can not find layer with id:": "", + "Error: missing details event target": "", + "Error: unknown layer type:": "", + "Esc": "", + "Escape": "", + "Español": "", + "Exit confirmation:": "", + "Expand edges": "", + "Exponent:": "", + "Export": "", + "External": "", + "Factor:": "", + "File": "", + "File name:": "", + "File size:": "", + "Fill": "", + "Fill Tool": "", + "Fit": "", + "Fit Window": "", + "Flatten Image": "", + "Flip": "", + "FloydSteinberg-serpentine": "", + "Font": "", + "Français": "", + "Full HD, 1080p": "", + "Full Screen": "", + "Full layers data": "", + "Gap:": "", + "Gaussian Blur": "", + "Gif delay:": "", + "Gingham": "", + "GitHub:": "", + "Gradient Radius:": "", + "Grains": "", + "Graphics Interchange Format": "", + "Gray": "", + "Grayscale": "", + "Greek": "", + "Green": "", + "Green channel:": "", + "Greyscale:": "", + "Grid": "", + "Grid on\/off": "", + "Guides": "", + "Guides enabled.": "", + "H Radius:": "", + "H. Align:": "", + "Heatmap": "", + "Height (%):": "", + "Height:": "", + "Help": "", + "Helvetica": "", + "Hermite": "", + "Hex": "", + "Histogram": "", + "Histogram:": "", + "Home": "", + "Horizontal": "", + "Horizontal Alignment": "", + "Horizontal blur:": "", + "Horizontal:": "", + "Hue": "", + "Hue Rotate": "", + "Hue:": "", + "Image": "", + "Image data with multi-layers. Can be opened using miniPaint -": "You can open asset (image) data with multi-layers using miniPaint. -", + "Impact": "", + "Increase": "", + "Information": "", + "Inkwell": "", + "Insert": "", + "Insert guides": "", + "Insert:": "", + "Instagram Filters": "", + "Invalid Hex Code": "", + "Italiano": "", + "JPG\/JPEG Format": "", + "Kerning:": "", + "Key-Points": "", + "KeyU": "", + "Keyboard Shortcuts": "", + "Keyword:": "", + "Lanczos": "", + "Language": "", + "Last modified": "", + "Layer": "", + "Layer details": "", + "Layer is not compatible with resize": "Sorry, this layer is not compatible with resize", + "Layer is vector, convert it to raster to apply this tool.": "Sorry this layer is a vector, please convert it to a raster to apply this tool. (Layer, Convert to a Raster)", + "Layers": "", + "Layers:": "", + "Left": "", + "Left to Right": "", + "Level:": "", + "Levels:": "", + "Lietuvių": "", + "Lo-fi": "", + "Luminance:": "", + "Luminosity": "", + "Magic Eraser Tool": "", + "Merge Down": "", + "Merge Layers": "", + "Merged": "", + "Metrics": "", + "Middle": "", + "Missing at least 1 size parameter.": "Sorry, you are missing at least 1 size parameter.", + "Missing permissions to write to Clipboard.cc": "", + "Mode:": "", + "Module function not found.": "", + "Modules class not found:": "", + "Monospace": "", + "Mosaic": "", + "Mouse:": "", + "Move": "", + "Move Layer": "", + "Move down": "", + "Move up": "", + "Name:": "", + "Needs at least 2 layers.": "You need at least 2 layers. Please make another layer using Layer, New or dragging a new asset (image) into the browser.", + "Negative": "", + "New": "", + "New Brush Layer": "", + "New Ellipse Layer": "", + "New File": "", + "New Gradient Layer": "", + "New Layer": "", + "New Line Layer": "", + "New Pencil Layer": "", + "New Rectangle Layer": "", + "New Text Layer": "", + "New file": "", + "New from Selection": "", + "New layer": "", + "New width can not be smaller then current width": "You can not make the new width smaller then current width.", + "Night Vision": "", + "None": "", + "Nothing is selected.": "Sorry, you have not selected anything, please try again.", + "Offset X:": "", + "Offset Y:": "", + "Oil": "", + "Ok": "", + "Online image editor.": "", + "Opacity": "", + "Opacity:": "", + "Open": "", + "Open Data URL": "", + "Open Directory": "", + "Open File": "", + "Open File Data URL": "", + "Open File URL": "", + "Open File Webcam": "", + "Open Image": "", + "Open JSON File": "", + "Open Test Template": "", + "Open URL": "", + "Open data URL": "", + "Open from Webcam": "", + "Original Size": "", + "PNGTOSVG - Convert Image to SVG": "", + "PageDown": "", + "PageUp": "", + "Palette": "", + "Parameter #1:": "", + "Parameter #2:": "", + "Paste": "", + "Pencil": "", + "Percentage:": "", + "Pixels:": "", + "Placeholder comment for color channels": "Placeholder comment for colour channels", + "Placeholder comment for color picker": "Placeholder comment for colour picker", + "Placeholder comment for color swatches": "Placeholder comment for colour swatches", + "Portable Network Graphics": "", + "Português": "", + "Position:": "", + "Power:": "", + "Preview": "", + "Previous": "", + "Previous layer must be image, convert it to raster to apply this tool.": "The previous layer must be an asset (image), please convert it to a raster to apply this tool.", + "Print": "", + "Quality:": "", + "Quick Load": "", + "Quick Save": "", + "REMOVE.BG - Remove Image Background": "", + "Radial": "", + "Radial gradient": "", + "Radius:": "", + "Range:": "", + "Red": "", + "Red channel:": "", + "Redo": "", + "Remove all": "", + "Rename": "", + "Rename Layer": "", + "Rendered with errors.": "", + "Rendering...": "", + "Replace Color": "Replace Colour", + "Replace color": "Replace colour", + "Replacement:": "", + "Report Issues": "", + "Reset": "", + "Resize": "", + "Resize Boundary": "", + "Resize Layer": "", + "Resize Layers": "", + "Resize Text Layer": "", + "Resized as background": "", + "Resized:": "", + "Resolution:": "", + "Restore Alpha": "", + "Right": "", + "Right angle:": "", + "Right to Left": "", + "Rotate": "", + "Rotate Layer": "", + "Rotate is not supported on this type of object. Convert to raster?": "Sorry, rotate is not supported on this type of asset (object), would you like to convert it to a raster?", + "Rotate left": "", + "Rotate:": "", + "Ruler": "", + "SQUOOSH - Compress and Compare Images": "", + "Safe search:": "", + "Saturate": "", + "Saturation": "", + "Saturation:": "", + "Save (Export)": "", + "Save As": "", + "Save As Data URL": "", + "Save as": "", + "Save as type:": "", + "Save layers:": "", + "Scaling up is not supported in Hermite, using Lanczos.": "", + "Scroll down": "", + "Scroll up": "", + "Search": "", + "Search Images": "", + "Search for Font": "", + "Select All": "", + "Select Text Layer": "", + "Select object tool": "", + "Selected": "", + "Selection Tool": "", + "Sensitivity:": "", + "Separated": "", + "Separated (original types)": "", + "Sepia": "", + "Set Image Size": "", + "Settings": "", + "Shadow": "", + "Shadow:": "", + "Shapes": "", + "Sharpen": "", + "Sharpen Tool": "", + "Sharpen:": "", + "Shortcut Key:": "", + "Show \/ Hide": "", + "Show file size:": "", + "Simple": "", + "Size is too big, max": "", + "Size:": "", + "Skip - layer must be image.": "Skip - layer must be an asset (image).", + "Solarize": "", + "Sorry, cold not load getUserMedia() data:": "Sorry, I could not load getUserMedia() data:", + "Sorry, image could not be loaded.": "Sorry, the asset (image) could not be loaded.", + "Sorry, image could not be loaded. Try copy image and paste it.": "Sorry, the asset (image) could not be loaded. Try copying the image and pasting it.", + "Sorry, image is too big, max 5 MB.": "Sorry, the asset (image) is too big, max size is 5 MB.", + "Source coordinates saved.": "", + "Source is empty, right click on image or use long press to save source position.": "Sorry, the source is empty, right click on the asset (image) or use a long press to save source position.", + "Sprites": "", + "Square": "", + "Stream:": "", + "Strength:": "", + "Strict": "", + "TINYPNG - Compress PNG and JPEG": "", + "Tab": "", + "Tag Image File Format": "", + "Tahoma": "", + "Target:": "", + "The quick brown fox jumps over the lazy dog.": "", + "Theme": "", + "There": "", + "There are no layers behind.": "", + "There is only 1 layer.": "", + "Thick guides:": "", + "This layer must contain an image. Please convert it to raster to apply this tool.": "Sorry, this layer must contain an asset (image). Please convert it to a raster to apply this tool.", + "Tilt Shift": "", + "Times New Roman": "", + "Toaster": "", + "Toggle": "", + "Toggle Color Channels": "Toggle Colour Channels", + "Toggle Color Picker": "Toggle Colour Picker", + "Toggle Menu": "", + "Toggle Swatches": "", + "Tools": "", + "Top": "", + "Top to Bottom": "", + "Total pixels:": "", + "Translate": "", + "Translate Layer": "", + "Translate error, can not find dictionary:": "Translate error, I can not find the dictionary:", + "Transparency background:": "", + "Transparent:": "", + "Trim": "", + "Trim Layers": "", + "Trim borders:": "", + "Trim layer:": "", + "Trim white color?": "Trim white colour?", + "Type:": "", + "Türkçe": "", + "Undo": "", + "Unique colors:": "Unique colours:", + "Units": "", + "Up": "", + "Update": "", + "Update Brush Layer": "", + "Update Pencil Layer": "", + "Update guides": "", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "You can use Ctrl+V on the keyboard shortcut to paste from the Clipboard.", + "V Radius:": "", + "V. Align:": "", + "Valencia": "", + "Verdana": "", + "Version:": "", + "Vertical": "", + "Vertical Alignment": "", + "Vertical blur:": "", + "Vertical:": "", + "Vibrance": "", + "View": "", + "Vignette": "", + "ViliusL": "", + "Vintage": "", + "Webcam": "", + "Webcam #": "", + "Website:": "", + "Weppy File Format": "", + "Width (%):": "", + "Width:": "", + "Windows Bitmap": "", + "Word": "", + "Word + Letter": "", + "Wrap At:": "", + "Wrap:": "", + "Wrong dimensions": "", + "Wrong file type, must be image or json.": "This is the wrong file type, it must be an asset (image) or json.", + "X end:": "", + "X position:": "", + "X start:": "", + "X-Pro II": "", + "Y end:": "", + "Y position:": "", + "Y start:": "", + "You can also drag and drop items into browser.": "You can also drag and drop assets (items) into browser.", + "Your browser does not support canvas or JavaScript is not enabled.": "", + "Your browser does not support this format.": "", + "Your search did not match any images.": "Your search did not match any assets (images).", + "Zoom": "", + "Zoom Blur": "", + "Zoom In": "", + "Zoom Out": "", + "Zoom blur": "", + "Zoom in": "", + "Zoom out": "", + "Zoom:": "" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/languages/zh.json b/paintplus/frontend/src/js/languages/zh.json new file mode 100644 index 0000000..5858f53 --- /dev/null +++ b/paintplus/frontend/src/js/languages/zh.json @@ -0,0 +1,535 @@ +{ + "A problem occurred while removing undo history. It": "删除撤销历史记录时发生问题。它", + "About": "关于", + "Active": "活动", + "Aden": "Aden", + "Advanced": "高级", + "All": "全部", + "Alpha": "透明度", + "Alpha:": "透明度:", + "Animation": "动画", + "Anonymous": "匿名", + "Anti aliasing": "抗锯齿", + "Application markup may have changed,": "应用标记可能已更改,", + "Arial": "Arial", + "Arrow": "箭头", + "ArrowDown": "向下箭头", + "ArrowLeft": "向左箭头", + "ArrowRight": "向右箭头", + "ArrowUp": "向上箭头", + "Author:": "作者:", + "Auto Adjust Colors": "自动调整颜色", + "Auto Kerning": "自动紧排", + "Auto select": "自动选择", + "Average:": "平均值:", + "Backspace": "退格键", + "Base": "基础", + "Basic": "基本", + "Black and White": "黑白", + "Blue": "蓝色", + "Blue channel:": "蓝色通道:", + "Blueprint": "蓝图", + "Blur": "模糊工具", + "Blur Radius:": "模糊半径:", + "Blur Tool": "模糊工具", + "Blur power:": "模糊强度:", + "Borders": "边框", + "Bottom": "底部", + "Bottom to Top": "从底部到顶部", + "Bounds:": "边界:", + "Box": "方框", + "Box Blur": "方框模糊", + "Box blur": "方框模糊", + "Brightness": "亮度", + "Brightness:": "亮度:", + "Brush": "刷子工具", + "Bulge": "凸出", + "Bulge/Pinch Tool": "凸出/收缩工具", + "Burn": "加深", + "Can not animate 1 layer.": "无法对1个图层进行动画。", + "Can not find previous layer.": "找不到上一个图层。", + "Can not use this tool on current layer: image already takes all area.": "无法在当前图层上使用此工具:图像已覆盖整个区域。", + "Cancel": "取消", + "Canvas Size": "画布尺寸", + "Center": "中心", + "Center x:": "中心x:", + "Center y:": "中心y:", + "Center:": "中心:", + "Change Composition": "更改合成", + "Change Layer Details": "更改图层详情", + "Change Opacity": "更改不透明度", + "Channel:": "通道:", + "Circle": "圆圈", + "Clarendon": "Clarendon", + "Clear": "清除", + "Clear Selection": "清除选区", + "Clone": "克隆工具", + "Clone Tool": "克隆工具", + "Clone count:": "克隆数量:", + "Clone tool disabled for resized image. Please rasterize first.": "对已调整大小的图像禁用克隆工具。请先栅格化。", + "Cloned edges": "克隆边缘", + "Close": "关闭", + "Color 1:": "颜色 1:", + "Color 2:": "颜色 2:", + "Color #": "颜色 #", + "Color Corrections": "颜色校正", + "Color Palette": "颜色调色板", + "Color Zoom": "颜色缩放", + "Color alpha value can not be zero.": "颜色的 alpha 值不能为零。", + "Color to Alpha": "颜色转换为透明", + "Color zoom": "颜色缩放", + "Color:": "颜色:", + "Colors": "颜色", + "Colors:": "颜色:", + "Common Filters": "常见滤镜", + "Composition": "合成", + "Composition:": "合成:", + "Content Fill": "内容填充", + "Contiguous": "连续", + "Contrast": "对比度", + "Contrast:": "对比度:", + "Convert layer to raster": "将图层转换为栅格图", + "Convert to Raster": "转换为栅格图", + "Copy Selection": "复制选区", + "Copy to Clipboard": "复制到剪贴板", + "Courier": "Courier", + "Crop": "裁剪工具", + "Crop Tool": "裁剪工具", + "Crop on rotated layer is not supported. Convert it to raster to continue.": "不支持旋转图层上的裁剪。请将其转换为位图以继续。", + "Ctrl + C": "Ctrl + C", + "Ctrl+A": "Ctrl+A", + "Ctrl+C": "Ctrl+C", + "Ctrl+P": "Ctrl+P", + "Ctrl+V": "Ctrl+V", + "Ctrl+Y": "Ctrl+Y", + "Ctrl+Z": "Ctrl+Z", + "Current": "当前", + "Current Color Preview": "当前颜色预览", + "Custom": "自定义", + "Data URL": "数据 URL", + "Data URL:": "数据 URL:", + "Decrease": "减少", + "Decrease Color Depth": "减少色彩深度", + "Degree:": "角度:", + "Del": "删除", + "Delay:": "延迟:", + "Delete": "删除", + "Delete Selection": "删除选区", + "Denoise": "降噪", + "Desaturate Tool": "去色工具", + "Description:": "描述:", + "Deutsch": "德语", + "Differences": "差异", + "Differences Down": "差异缩小", + "Direction:": "方向:", + "Dither": "抖动", + "Dithering:": "抖动:", + "Dominant color:": "主色调:", + "Dot Screen": "点阵", + "Down": "向下", + "Duplicate": "复制", + "Duplicate Layer": "复制图层", + "Duplicate layer": "复制图层", + "Dynamic": "动态", + "Edge": "边缘", + "Edit": "编辑", + "Edit text...": "编辑文本...", + "Effect browser": "特效浏览器", + "Effects": "特效", + "Effects browser": "特效浏览器", + "Email:": "邮箱:", + "Emboss": "浮雕", + "Empty selection": "空选区", + "Empty selection or type not image.": "空选区或未输入图像。", + "Enable autoresize:": "启用自动调整大小:", + "End": "结束", + "English": "英语", + "English (UK)": "英语(英国)", + "Enrich": "增强", + "Enter": "输入", + "Erase Tool": "橡皮擦工具", + "Erase on rotate object is disabled. Please rasterize first.": "禁用旋转对象上的橡皮擦。请先栅格化。", + "Error": "错误", + "Error connecting to service.": "连接到服务时出错。", + "Error loading the list of fonts from Google.": "加载 Google 字体列表时出错。", + "Error registering service worker": "注册服务工作者时出错", + "Error: can not find filter:": "错误:无法找到滤镜:", + "Error: can not find layer with id:": "错误:无法找到带有 ID 的图层:", + "Error: missing details event target": "错误:缺少详细信息事件目标", + "Error: unknown layer type:": "错误:未知的图层类型:", + "Error: unsupported attribute type:": "错误:不支持的属性类型:", + "Esc": "退出", + "Escape": "逃脱", + "Español": "西班牙语", + "Expand edges": "扩展边缘", + "Exponent:": "指数:", + "Export": "导出", + "External": "外部", + "Erase": "橡皮擦工具", + "Factor:": "因子:", + "File": "文件", + "File name:": "文件名:", + "File size:": "文件大小:", + "Fill": "填充", + "Fill:": "填充:", + "Fill Tool": "填充工具", + "Fit": "适应", + "Fit Window": "适应窗口", + "Fit window": "适应窗口", + "Flatten Image": "图像拉平", + "Flip": "翻转", + "FloydSteinberg-serpentine": "FloydSteinberg-蛇形", + "Font": "字体", + "Font:": "字体:", + "Français": "法语", + "Full HD, 1080p": " 全高清,1080p", + "Full Screen": "全屏", + "Full layers data": "全层数据", + "Gap:": "间距:", + "Gaussian Blur": "高斯模糊", + "Gif delay:": "动图延迟:", + "Gingham": "方格", + "GitHub:": "GitHub:", + "Gradient": "渐变工具", + "Gradient Radius:": "渐变半径:", + "Grains": "颗粒", + "Graphics Interchange Format": "图形交换格式", + "Gray": "灰色", + "Grayscale": "灰度", + "Greek": "希腊语", + "Green": "绿色", + "Green channel:": "绿色通道:", + "Greyscale:": "灰度:", + "Grid": "网格", + "Grid on/off": "打开/关闭网格", + "Guides": "参考线", + "Guides enabled.": "参考线已启用。", + "H Radius:": "水平半径:", + "H. Align:": "水平对齐:", + "Heatmap": "热力图", + "Height (%):": "高度(%):", + "Height:": "高度:", + "Help": "帮助", + "Helvetica": "黑体", + "Hermite": "Hermite", + "Hex": "十六进制", + "Hide": "隐藏", + "Histogram": "直方图", + "Histogram:": "直方图:", + "Home": "主页", + "Horizontal": "水平", + "Horizontal Alignment": "水平对齐", + "Horizontal blur:": "水平模糊:", + "Horizontal:": "水平:", + "Hue": "色调", + "Hue Rotate": "色调旋转", + "Hue:": "色调:", + "Image": "图片", + "Image data with multi-layers. Can be opened using miniPaint -": "图像数据带有多层。可使用miniPaint打开 -", + "Impact": "影响", + "In proportion:": "按比例:", + "Increase": "增加", + "Information": "信息", + "Inkwell": "墨井", + "Insert": "插入", + "Insert guides": "插入参考线", + "Insert new layer": "插入新图层", + "Instagram Filters": "Instagram 滤镜", + "Invalid Hex Code": "无效的十六进制代码", + "Italiano": "意大利语", + "JPG/JPEG Format": "JPG / JPEG格式", + "Kerning:": "字距:", + "Key-Points": "关键点", + "KeyU": "键 U", + "Keyboard Shortcuts": "键盘快捷键", + "Keyword:": "关键字:", + "Lanczos": "Lanczos", + "Landscape": "横向", + "Language": "语言", + "Last modified": "上次修改", + "Layer": "图层", + "Layer details": "图层详情", + "Layer is empty.": "图层为空。", + "Layer is not compatible with resize": "图层不兼容调整大小", + "Layer is vector, convert it to raster to apply this tool.": "图层为矢量,转换为栅格以应用此工具。", + "Layers": "图层", + "Layers:": "图层:", + "Layout:": "布局:", + "Leading:": "行距:", + "Left": "左", + "Left to Right": "左到右", + "Level:": "层级:", + "Levels:": "层级:", + "Lietuvių": "立陶宛语", + "Lo-fi": "低保真", + "Luminance:": "亮度:", + "Luminosity": "亮度", + "Magic Eraser Tool": "魔术橡皮擦工具", + "Merge Down": "向下合并", + "Merge Layers": "合并图层", + "Merged": "已合并", + "Metrics": "指标", + "Middle": "居中", + "Missing at least 1 size parameter.": "至少缺少1个尺寸参数。", + "Missing permissions to write to Clipboard.cc": "缺少写入Clipboard.cc的权限", + "Mode:": "模式:", + "Module function not found.": "未找到模块功能。", + "Modules class not found:": "未找到模块类:", + "Monospace": "等宽字体", + "Mosaic": "马赛克", + "Mouse:": "鼠标:", + "Move": "移动", + "Move Layer": "移动图层", + "Move layer down": "向下移动图层", + "Move layer up": "向上移动图层", + "Name:": "名称:", + "Negative": "负片", + "New": "新建", + "New Bezier Layer": "新贝塞尔曲线图层", + "New Brush Layer": "新画笔图层", + "New Ellipse Layer": "新椭圆图层", + "New File": "新建文件", + "New Gradient Layer": "新渐变图层", + "New Layer": "新建图层", + "New Line Layer": "新线条图层", + "New Pencil Layer": "新铅笔图层", + "New Polygon Layer": "新多边形图层", + "New Rectangle Layer": "新矩形图层", + "New Text Layer": "新文本图层", + "New file": "新建文件", + "New from Selection": "从选择新建", + "New layer": "新建图层", + "Next": "下一个", + "Night Vision": "夜视", + "None": "无", + "Nothing is selected.": "未选择任何内容。", + "Offset X:": "X偏移:", + "Offset Y:": "Y偏移:", + "Oil": "油画", + "Ok": "确定", + "Online image editor.": "在线图像编辑器。", + "Opacity": "不透明度", + "Opacity:": "不透明度:", + "Open": "打开", + "Open Data URL": "打开数据URL", + "Open Directory": "打开目录", + "Open File": "打开文件", + "Open File Data URL": "打开数据URL文件", + "Open File URL": "打开文件网址", + "Open File Webcam": "打开网络摄像头文件", + "Open Image": "打开图像", + "Open JSON File": "打开JSON文件", + "Open Test Template": "打开测试模板", + "Open URL": "打开网址", + "Open data URL": "打开数据URL", + "Open from Webcam": "从摄像头打开", + "Original Size": "原始大小", + "PNGTOSVG - Convert Image to SVG": "PNGTOSVG - 将图像转换为SVG格式", + "PageDown": "下一页", + "PageUp": "上一页", + "Palette": "调色板", + "Parameter #1:": "参数1:", + "Parameter #2:": "参数2:", + "Paste": "粘贴", + "Pencil": "铅笔工具", + "Percentage:": "百分比:", + "Pick color": "吸管工具", + "Pixels:": "像素:", + "Placeholder comment for color channels": "颜色通道的占位符注释", + "Placeholder comment for color picker": "颜色选择器的占位符注释", + "Placeholder comment for color swatches": "颜色样本的占位符注释", + "Play": "播放", + "Portable Network Graphics": "便携式网络图形", + "Portrait": "纵向", + "Português": "葡萄牙语", + "Position:": "位置:", + "Power:": "功率:", + "Preview": "预览", + "Previous": "上一个", + "Previous layer must be image, convert it to raster to apply this tool.": "前一图层必须为图像,将其转换为栅格以应用此工具。", + "Print": "打印", + "Quality:": "质量:", + "Quick Load": "快速加载", + "Quick Save": "快速保存", + "REMOVE.BG - Remove Image Background": "REMOVE.BG - 移除图像背景", + "Radial": "径向", + "Radial gradient": "径向渐变", + "Radius:": "半径:", + "Range:": "范围:", + "Red": "红色", + "Red channel:": "红色通道:", + "Redo": "重做", + "Remove all": "移除全部", + "Rename": "重命名", + "Rename Layer": "重命名图层", + "Rendered with errors.": "渲染出现错误。", + "Rendering...": "渲染中...", + "Replace Color": "替换颜色", + "Replace color": "替换颜色", + "Replacement:": "替换:", + "Report Issues": "报告问题", + "Reset": "重置", + "Resize": "调整大小", + "Resize Boundary": "调整边界", + "Resize Layer": "调整图层大小", + "Resize Layers": "调整图层大小", + "Resize Text Layer": "调整文本图层大小", + "Resized as background": "调整为背景", + "Resized:": "调整大小:", + "Resolution:": "分辨率:", + "Restore Alpha": "恢复透明度", + "Right": "右", + "Right angle:": "直角:", + "Right to Left": "从右到左", + "Rotate": "旋转", + "Rotate Layer": "旋转图层", + "Rotate is not supported on this type of object. Convert to raster?": "此类型对象不支持旋转。转换为栅格图?", + "Rotate left": "向左旋转", + "Rotate:": "旋转:", + "Ruler": "标尺", + "SQUOOSH - Compress and Compare Images": "SQUOOSH - 压缩和比较图像", + "Saturate": "饱和度", + "Saturation": "饱和度", + "Saturation:": "饱和度:", + "Save As": "另存为", + "Save As Data URL": "另存为数据URL", + "Save as": "另存为", + "Save as type:": "另存为类型:", + "Save layers:": "保存图层:", + "Scaling up is not supported in Hermite, using Lanczos.": "Hermite不支持放大,请使用Lanczos。", + "Scroll down": "向下滚动", + "Scroll up": "向上滚动", + "Search": "搜索", + "Search Images": "搜索图像", + "Search for Font": "搜索字体", + "Search:": "搜索:", + "Select All": "全选", + "Select Text Layer": "选择文本图层", + "Select object tool": "选择对象工具", + "Selected": "已选择", + "Selection": "选择工具", + "Selection Tool": "选择工具", + "Sensitivity:": "灵敏度:", + "Separated": "分离", + "Separated (original types)": "分离(原始类型)", + "Sepia": "棕褐色", + "Set Image Size": "设置图像尺寸", + "Settings": "设置", + "Shadow": "阴影", + "Shapes": "形状", + "Shapes (H)": "形状 (H)", + "Sharpen": "锐化", + "Sharpen Tool": "锐化工具", + "Sharpen:": "锐化:", + "Shift + S": "Shift + S", + "Shortcut Key:": "快捷键:", + "Show": "显示", + "Show \/ Hide": "显示 \/ 隐藏", + "Show file size:": "显示文件大小:", + "Simple": "简单", + "Size is too big, max": "尺寸太大,最大值为", + "Size:": "尺寸:", + "Skip - layer must be image.": "跳过 - 图层必须是图像。", + "Solarize": "曝光反转", + "Sorry, cold not load getUserMedia() data:": "抱歉,无法加载 getUserMedia() 数据:", + "Sorry, image could not be loaded.": "抱歉,无法加载图片。", + "Sorry, image could not be loaded. Try copy image and paste it.": "抱歉,图片无法加载。尝试复制图像并粘贴。", + "Sorry, image is too big, max 5 MB.": "抱歉,图片太大,最大值为5 MB。", + "Source coordinates saved.": "源坐标已保存。", + "Source is empty, right click on image or use long press to save source position.": "源为空,右键单击图像或长按保存源位置。", + "Source layer:": "源图层:", + "Sprites": "图像精灵", + "Square": "正方形", + "Stream:": "流:", + "Strength:": "强度:", + "Strict": "严格", + "Stroke size:": "线条粗细:", + "TINYPNG - Compress PNG and JPEG": "TINYPNG - 压缩PNG和JPEG", + "Tab": "标签", + "Tag Image File Format": "标记图像文件格式", + "Tahoma": "Tahoma", + "Target:": "目标:", + "The quick brown fox jumps over the lazy dog.": "敏捷的棕色狐狸跳过了懒狗。", + "There": "那里", + "There are no layers behind.": "背后没有图层。", + "There is only 1 layer.": "只有1个图层。", + "This layer must contain an image. Please convert it to raster to apply this tool.": "此图层必须包含图像。请将其转换为光栅以应用此工具。", + "Tilt Shift": "视角移位", + "Times New Roman": "Times New Roman", + "Toaster": "Toaster", + "Toggle": "切换", + "Toggle Color Channels": "切换颜色通道", + "Toggle Color Picker": "切换颜色选择器", + "Toggle Menu": "切换菜单", + "Toggle Swatches": "切换样本", + "Tools": "工具", + "Top": "顶部", + "Top to Bottom": "从上到下", + "Total pixels:": "总像素数:", + "Translate": "翻译", + "Translate Layer": "翻译图层", + "Translate error, can not find dictionary:": "翻译错误,找不到字典:", + "Transparent:": "透明:", + "Trim": "裁剪", + "Trim Layers": "裁剪图层", + "Trim borders:": "裁剪边框:", + "Trim layer:": "裁剪图层:", + "Trim white color?": "裁剪白色吗?", + "Text": " 文本工具", + "Type:": "类型:", + "Türkçe": "土耳其语", + "Undo": "撤销", + "Unique colors:": "唯一颜色:", + "Up": "向上", + "Update": "更新", + "Update Brush Layer": "更新画笔图层", + "Update Pencil Layer": "更新铅笔图层", + "Update guides": "更新指南", + "Use Ctrl+V keyboard shortcut to paste from Clipboard.": "使用 Ctrl+V 快捷键从剪贴板粘贴。", + "V Radius:": "垂直半径:", + "V. Align:": "垂直对齐:", + "Valencia": "Valencia", + "Verdana": "Verdana", + "Version:": "版本:", + "Vertical": "垂直", + "Vertical Alignment": "垂直对齐", + "Vertical blur:": "垂直模糊:", + "Vertical:": "垂直:", + "Vibrance": "饱和度", + "View": "视图", + "Vignette": "晕影", + "ViliusL": "ViliusL", + "Vintage": "复古", + "Webcam": "摄像头", + "Webcam #": "摄像头 #", + "Website:": "网站:", + "Weppy File Format": "Weppy文件格式", + "Width (%):": "宽度(%):", + "Width:": "宽度:", + "Windows Bitmap": "Windows位图", + "Word": "词", + "Word + Letter": "词 + 字母", + "Wrap At:": "在此处换行:", + "Wrap:": "自动换行:", + "Wrong dimensions": "尺寸错误", + "Wrong file type, must be image or json.": "文件类型错误,必须是图像或JSON。", + "X end:": "X 结束:", + "X position:": "X 位置:", + "X start:": "X 开始:", + "X-Pro II": "X-Pro II", + "Y end:": "Y 结束:", + "Y position:": "Y 位置:", + "Y start:": "Y 开始:", + "You can also drag and drop items into browser.": "您也可以将项目拖放到浏览器中。", + "Your browser does not support canvas or JavaScript is not enabled.": "您的浏览器不支持画布或JavaScript未启用。", + "Your browser does not support this format.": "您的浏览器不支持此格式。", + "Your search did not match any images.": "您的搜索未匹配任何图片。", + "Zoom": "缩放", + "Zoom Blur": "缩放模糊", + "Zoom In": "放大", + "Zoom Out": "缩小", + "Zoom blur": "缩放模糊", + "Zoom in": "放大", + "Zoom out": "缩小", + "Zoom:": "缩放:" +} \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/canvastotiff.js b/paintplus/frontend/src/js/libs/canvastotiff.js new file mode 100644 index 0000000..9f4a472 --- /dev/null +++ b/paintplus/frontend/src/js/libs/canvastotiff.js @@ -0,0 +1,309 @@ +/*! + canvas-to-tiff version 1.0.0 + By Epistemex (c) 2015-2016 + www.epistemex.com + MIT License (this header required) +*/ + +/** + * Static helper object that can convert a CORS-compliant canvas element + * to a 32-bits TIFF file (buffer, Blob and data-URI). The TIFF is by + * default saved in big-endian format with interleaved RGBA data. + * + * @type {{toArrayBuffer: Function, toBlob: Function, toDataURL: Function}} + * @namespace + */ +var CanvasToTIFF = { + + /** + * @private + */ + _dly: 9, + + /** + * @private + */ + _error: null, + + /** + * Add error handler (function) in case of any error + * @param fn + */ + setErrorHandler: function(fn) { + this._error = fn + }, + + /** + * Convert a canvas element to ArrayBuffer containing a TIFF file + * with support for alpha. The call is asynchronous + * so a callback must be provided. + * + * Note that CORS requirement must be fulfilled. + * + * @param {HTMLCanvasElement} canvas - the canvas element to convert + * @param {function} callback - called when conversion is done. Argument is ArrayBuffer + * @param {object} [options] - an option object + * @param {boolean} [options.littleEndian=false] - set to true to produce a little-endian based TIFF + * @param {number} [options.dpi=96] - DPI for both X and Y directions. Default 96 DPI (PPI). + * @param {number} [options.dpiX=96] - DPI for X directions (overrides options.dpi). + * @param {number} [options.dpiY=96] - DPI for Y directions (overrides options.dpi). + * @static + */ + toArrayBuffer: function(canvas, callback, options) { + + options = options || {}; + + var me = this; + + try { + var w = canvas.width, + h = canvas.height, + offset = 0, + iOffset = 258, // todo calc based on offset field length, add to final offset when compiled + //iOffsetPtr, + entries = 0, + offsetList = [], + idfOffset, + sid = "\x63\x61\x6e\x76\x61\x73\x2d\x74\x6f\x2d\x74\x69\x66\x66\x20\x30\x2e\x34\0", + lsb = !!options.littleEndian, + dpiX = +(options.dpiX || options.dpi || 96)|0, + dpiY = +(options.dpiY || options.dpi || 96)|0, + idata = canvas.getContext("2d").getImageData(0, 0, w, h), + length = idata.data.length, + fileLength = iOffset + length, + file = new ArrayBuffer(fileLength), + file8 = new Uint8Array(file), + view = new DataView(file), + pos = 0, + date = new Date(), + dateStr; + + // Header + set16(lsb ? 0x4949 : 0x4d4d); // II or MM + set16(42); // magic 42 + set32(8); // offset to first IFD + + // IFD + addIDF(); // IDF start + addEntry(0xfe, 4, 1, 0); // NewSubfileType + addEntry(0x100, 4, 1, w); // ImageWidth + addEntry(0x101, 4, 1, h); // ImageLength (height) + addEntry(0x102, 3, 4, offset, 8); // BitsPerSample + addEntry(0x103, 3, 1, 1); // Compression + addEntry(0x106, 3, 1, 2); // PhotometricInterpretation: RGB + addEntry(0x111, 4, 1, iOffset, 0); // StripOffsets + addEntry(0x115, 3, 1, 4); // SamplesPerPixel + addEntry(0x117, 4, 1, length); // StripByteCounts + addEntry(0x11a, 5, 1, offset, 8); // XResolution + addEntry(0x11b, 5, 1, offset, 8); // YResolution + addEntry(0x128, 3, 1, 2); // ResolutionUnit: inch + addEntry(0x131, 2, sid.length, offset, getStrLen(sid)); // sid + addEntry(0x132, 2, 0x14, offset, 0x14); // Datetime + addEntry(0x152, 3, 1, 2); // ExtraSamples + endIDF(); + + // Fields section > long --------------------------- + + // BitsPerSample (2x4), 8,8,8,8 + set32(0x00080008); + set32(0x00080008); + + // StripOffset to bitmap data + //set32(iOffset); + + // StripByteCounts + //set32(length); + + // XRes PPI + set32(dpiX); + set32(1); + + // YRes PPI + set32(dpiY); + set32(1); + + // sid + setStr(sid); + + // date + dateStr = date.getFullYear() + ":" + pad2(date.getMonth() + 1) + ":" + pad2(date.getDate()) + " "; + dateStr += pad2(date.getHours()) + ":" + pad2(date.getMinutes()) + ":" + pad2(date.getSeconds()); + setStr(dateStr); + + // Image data here (todo if very large, split into block based copy) + file8.set(idata.data, iOffset); + + // make actual async + setTimeout(function() { callback(file) }, me._dly); + } + catch(err) { + if (me._error) me._error(err.toString()) + } + + function pad2(str) { + str += ""; + return str.length === 1 ? "0" + str : str + } + + // helper method to move current buffer position + function set16(data) { + view.setUint16(pos, data, lsb); + pos += 2 + } + + function set32(data) { + view.setUint32(pos, data, lsb); + pos += 4 + } + + function setStr(str) { + var i = 0; + while(i < str.length) view.setUint8(pos++, str.charCodeAt(i++) & 0xff, lsb); + if (pos & 1) pos++ + } + + function getStrLen(str) { + var l = str.length; + return l & 1 ? l + 1 : l + } + + function addEntry(tag, type, count, value, dltOffset) { + set16(tag); + set16(type); + set32(count); + + if (dltOffset) { + //if (tag === 0x111) iOffsetPtr = pos; + //iOffset += dltOffset; + offset += dltOffset; + offsetList.push(pos); + } + + if (count === 1 && type === 3 && !dltOffset) { + set16(value); + set16(0); // pad + } + else { + set32(value); + } + + entries++ + } + + function addIDF(offset) { + idfOffset = offset || pos; + pos += 2; + } + + function endIDF() { + view.setUint16(idfOffset, entries, lsb); + set32(0); + + var delta = 14 + entries * 12; // 14 = offset to IDF (8) + IDF count (2) + end pointer (4) + + // compile offsets + for(var i = 0, p, o; i < offsetList.length; i++) { + p = offsetList[i]; + o = view.getUint32(p, lsb); + view.setUint32(p, o + delta, lsb); + } + + //view.setUint32(iOffsetPtr, iOffset + delta, lsb); + } + }, + + /** + * Converts a canvas to TIFF file, returns a Blob representing the + * file. This can be used with URL.createObjectURL(). The call is + * asynchronous so a callback must be provided. + * + * Note that CORS requirement must be fulfilled. + * + * @param {HTMLCanvasElement} canvas - the canvas element to convert + * @param {function} callback - called when conversion is done. Argument is a Blob + * @param {object} [options] - an option object - see toArrayBuffer for details + * @static + */ + toBlob: function(canvas, callback, options) { + this.toArrayBuffer(canvas, function(file) { + callback(new Blob([file], {type: "image/tiff"})); + }, options || {}); + }, + + /** + * Converts a canvas to TIFF file, returns an ObjectURL (for Blob) + * representing the file. The call is asynchronous so a callback + * must be provided. + * + * **Important**: To avoid memory-leakage you must revoke the returned + * ObjectURL when no longer needed: + * + * var _URL = self.URL || self.webkitURL || self; + * _URL.revokeObjectURL(url); + * + * Note that CORS requirement must be fulfilled. + * + * @param {HTMLCanvasElement} canvas - the canvas element to convert + * @param {function} callback - called when conversion is done. Argument is a Blob + * @param {object} [options] - an option object - see toArrayBuffer for details + * @static + */ + toObjectURL: function(canvas, callback, options) { + this.toBlob(canvas, function(blob) { + var url = self.URL || self.webkitURL || self; + callback(url.createObjectURL(blob)) + }, options || {}); + }, + + /** + * Converts the canvas to a data-URI representing a BMP file. The + * call is asynchronous so a callback must be provided. + * + * Note that CORS requirement must be fulfilled. + * + * @param {HTMLCanvasElement} canvas - the canvas element to convert + * @param {function} callback - called when conversion is done. Argument is an data-URI (string) + * @param {object} [options] - an option object - see toArrayBuffer for details + * @static + */ + toDataURL: function(canvas, callback, options) { + + var me = this; + + me.toArrayBuffer(canvas, function(file) { + var buffer = new Uint8Array(file), + blockSize = 1<<20, + block = blockSize, + bs = "", base64 = "", i = 0, l = buffer.length; + + // This is a necessary step before we can use btoa. We can + // replace this later with a direct byte-buffer to Base-64 routine. + // Will do for now, impacts only with very large bitmaps (in which + // case toBlob should be used). + (function prepBase64() { + while(i < l && block-- > 0) bs += String.fromCharCode(buffer[i++]); + + if (i < l) { + block = blockSize; + setTimeout(prepBase64, me._dly); + } + else { + // convert string to Base-64 + i = 0; + l = bs.length; + block = 180000; // must be divisible by 3 + + (function toBase64() { + base64 += btoa(bs.substr(i, block)); + i += block; + (i < l) + ? setTimeout(toBase64, me._dly) + : callback("data:image/tiff;base64," + base64); + })(); + } + })(); + }, options || {}); + } +}; + +export default CanvasToTIFF; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/clipboard.js b/paintplus/frontend/src/js/libs/clipboard.js new file mode 100644 index 0000000..c885551 --- /dev/null +++ b/paintplus/frontend/src/js/libs/clipboard.js @@ -0,0 +1,152 @@ +import Helper_class from './helpers.js'; + +/** + * image pasting into canvas + * + * @param {string} canvas_id - canvas id + * @param {boolean} autoresize - if canvas will be resized + */ +class Clipboard_class { + + constructor(on_paste) { + var _self = this; + + this.Helper = new Helper_class(); + + this.on_paste = on_paste; + this.ctrl_pressed = false; + this.command_pressed = false; + this.pasteCatcher; + this.paste_mode; + + //handlers + document.addEventListener('keydown', function (e) { + _self.on_keyboard_action(e); + }, false); //firefox fix + document.addEventListener('keyup', function (e) { + _self.on_keyboardup_action(e); + }, false); //firefox fix + document.addEventListener('paste', function (e) { + _self.paste_auto(e); + }, false); //official paste handler + + this.init(); + } + + //constructor - prepare + init() { + var _self = this; + + //if using auto + if (window.Clipboard) + return true; + + this.pasteCatcher = document.createElement("div"); + this.pasteCatcher.setAttribute("id", "paste_ff"); + this.pasteCatcher.setAttribute("contenteditable", ""); + this.pasteCatcher.style.cssText = 'opacity:0;position:fixed;top:0px;left:0px;'; + this.pasteCatcher.style.marginLeft = "-20px"; + this.pasteCatcher.style.width = "10px"; + document.body.appendChild(this.pasteCatcher); + + // create an observer instance + var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + if (this.paste_mode == 'auto' || this.ctrl_pressed == false || mutation.type != 'childList') + return true; + + //if paste handle failed - capture pasted object manually + if (mutation.addedNodes.length == 1) { + if (mutation.addedNodes[0].src != undefined) { + //image + _self.paste_createImage(mutation.addedNodes[0].src); + } + //register cleanup after some time. + setTimeout(function () { + this.pasteCatcher.innerHTML = ''; + }, 20); + } + }); + }); + var target = document.getElementById('paste_ff'); + var config = {attributes: true, childList: true, characterData: true}; + observer.observe(target, config); + } + + //default paste action + paste_auto(e) { + if (this.Helper.is_input(e.target)) + return; + + this.paste_mode = ''; + if (!window.Clipboard) { + this.pasteCatcher.innerHTML = ''; + } + if (e.clipboardData) { + var items = e.clipboardData.items; + if (items) { + this.paste_mode = 'auto'; + //access data directly + for (var i = 0; i < items.length; i++) { + if (items[i].type.indexOf("image") !== -1) { + //image + var blob = items[i].getAsFile(); + var URLObj = window.URL || window.webkitURL; + var source = URLObj.createObjectURL(blob); + this.paste_createImage(source); + } + } + e.preventDefault(); + } + else { + //wait for DOMSubtreeModified event + //https://bugzilla.mozilla.org/show_bug.cgi?id=891247 + } + } + } + + //on keyboard press + on_keyboard_action(event) { + var k = event.keyCode; + //ctrl + if (k == 17 || event.metaKey || event.ctrlKey) { + if (this.ctrl_pressed == false) + this.ctrl_pressed = true; + } + //v + if (k == 86) { + if (this.Helper.is_input(document.activeElement)) { + return false; + } + + if (this.ctrl_pressed == true && !window.Clipboard) + this.pasteCatcher.focus(); + } + } + + //on kaybord release + on_keyboardup_action(event) { + //ctrl + if (event.ctrlKey == false && this.ctrl_pressed == true) { + this.ctrl_pressed = false; + } + //command + else if (event.metaKey == false && this.command_pressed == true) { + this.command_pressed = false; + this.ctrl_pressed = false; + } + } + + //draw image + paste_createImage(source) { + var pastedImage = new Image(); + var _this = this; + + pastedImage.onload = function () { + _this.on_paste(source, pastedImage.width, pastedImage.height); + }; + pastedImage.src = source; + } +} + +export default Clipboard_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/color-matrix.js b/paintplus/frontend/src/js/libs/color-matrix.js new file mode 100644 index 0000000..1d38577 --- /dev/null +++ b/paintplus/frontend/src/js/libs/color-matrix.js @@ -0,0 +1,115 @@ +/** + * Color Matrix + * + * A simplification of the color matrix class provided by + * EaselJS + * + * www.createjs.com/docs/easeljs/files/easeljs_filters_ColorFilter.js.html#l41 + */ + +class colorMatrix { + + constructor(on_paste) { + this.DELTA_INDEX = [ + 0, 0.01, 0.02, 0.04, 0.05, 0.06, 0.07, 0.08, 0.1, 0.11, + 0.12, 0.14, 0.15, 0.16, 0.17, 0.18, 0.20, 0.21, 0.22, 0.24, + 0.25, 0.27, 0.28, 0.30, 0.32, 0.34, 0.36, 0.38, 0.40, 0.42, + 0.44, 0.46, 0.48, 0.5, 0.53, 0.56, 0.59, 0.62, 0.65, 0.68, + 0.71, 0.74, 0.77, 0.80, 0.83, 0.86, 0.89, 0.92, 0.95, 0.98, + 1.0, 1.06, 1.12, 1.18, 1.24, 1.30, 1.36, 1.42, 1.48, 1.54, + 1.60, 1.66, 1.72, 1.78, 1.84, 1.90, 1.96, 2.0, 2.12, 2.25, + 2.37, 2.50, 2.62, 2.75, 2.87, 3.0, 3.2, 3.4, 3.6, 3.8, + 4.0, 4.3, 4.7, 4.9, 5.0, 5.5, 6.0, 6.5, 6.8, 7.0, + 7.3, 7.5, 7.8, 8.0, 8.4, 8.7, 9.0, 9.4, 9.6, 9.8, + 10.0 + ]; + } + + multiply (a, b) { + var i, j, k, col = []; + + for (i=0;i<5;i++) { + for (j=0;j<5;j++) { + col[j] = a[j+i*5]; + } + for (j=0;j<5;j++) { + var val=0; + for (k=0;k<5;k++) { + val += b[j+k*5]*col[k]; + } + a[j+i*5] = val; + } + } + } + + colorMatrix (imageData, options) { + var brightness = options.brightness || 0; + var contrast = options.contrast || 0; + + var matrix = [ + 1,0,0,0,0, + 0,1,0,0,0, + 0,0,1,0,0, + 0,0,0,1,0, + 0,0,0,0,1 + ]; + + // Contrast + var x; + if (contrast < 0) { + x = 127 + contrast / 100 * 127; + } else { + x = contrast % 1; + + if (x == 0) { + x = this.DELTA_INDEX[contrast]; + } else { + x = this.DELTA_INDEX[(contrast<<0)]*(1-x)+this.DELTA_INDEX[(contrast<<0)+1] * x; + } + + x = x * 127 + 127; + } + + this.multiply (matrix, [ + x/127,0,0,0,0.5*(127-x), + 0,x/127,0,0,0.5*(127-x), + 0,0,x/127,0,0.5*(127-x), + 0,0,0,1,0, + 0,0,0,0,1 + ]); + + // Brightness + this.multiply (matrix,[ + 1,0,0,0, brightness, + 0,1,0,0, brightness, + 0,0,1,0, brightness, + 0,0,0,1,0, + 0,0,0,0,1 + ]) + + // Apply Filter + var data = imageData.data; + var l = data.length; + var r,g,b,a; + var m0 = matrix[0], m1 = matrix[1], m2 = matrix[2], m3 = matrix[3], m4 = matrix[4]; + var m5 = matrix[5], m6 = matrix[6], m7 = matrix[7], m8 = matrix[8], m9 = matrix[9]; + var m10 = matrix[10], m11 = matrix[11], m12 = matrix[12], m13 = matrix[13], m14 = matrix[14]; + var m15 = matrix[15], m16 = matrix[16], m17 = matrix[17], m18 = matrix[18], m19 = matrix[19]; + + for (var i=0; i 256) { + colorCount = 10; + } + if (typeof quality === 'undefined' || quality < 1) { + quality = 10; + } + + // Create custom CanvasImage object + var image = new CanvasImage(sourceImage); + var imageData = image.getImageData(); + var pixels = imageData.data; + var pixelCount = image.getPixelCount(); + + // Store the RGB values in an array format suitable for quantize function + var pixelArray = []; + for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) { + offset = i * 4; + r = pixels[offset + 0]; + g = pixels[offset + 1]; + b = pixels[offset + 2]; + a = pixels[offset + 3]; + // If pixel is mostly opaque and not white + if (a >= 125) { + if (!(r > 250 && g > 250 && b > 250)) { + pixelArray.push([r, g, b]); + } + } + } + + // Send array to quantize function which clusters values + // using median cut algorithm + var cmap = MMCQ.quantize(pixelArray, colorCount); + var palette = cmap? cmap.palette() : null; + + // Clean up + image.removeCanvas(); + + return palette; +}; + +ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) { + sourceImage = document.createElement("img"); + var thief = this; + sourceImage.addEventListener('load' , function(){ + var palette = thief.getPalette(sourceImage, 5, quality); + var dominantColor = palette[0]; + callback(dominantColor, imageUrl); + }); + sourceImage.src = imageUrl +}; + + +ColorThief.prototype.getImageData = function(imageUrl, callback) { + xhr = new XMLHttpRequest(); + xhr.open('GET', imageUrl, true); + xhr.responseType = 'arraybuffer' + xhr.onload = function(e) { + if (this.status == 200) { + uInt8Array = new Uint8Array(this.response) + i = uInt8Array.length + binaryString = new Array(i); + for (var i = 0; i < uInt8Array.length; i++){ + binaryString[i] = String.fromCharCode(uInt8Array[i]) + } + data = binaryString.join('') + base64 = window.btoa(data) + callback ("data:image/png;base64,"+base64) + } + } + xhr.send(); +}; + +ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) { + var thief = this; + this.getImageData(imageUrl, function(imageData){ + sourceImage = document.createElement("img"); + sourceImage.addEventListener('load' , function(){ + var palette = thief.getPalette(sourceImage, 5, quality); + var dominantColor = palette[0]; + callback(dominantColor, this); + }); + sourceImage.src = imageData; + }); +}; + + + +/*! + * quantize.js Copyright 2008 Nick Rabinowitz. + * Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php + * @license + */ + +// fill out a couple protovis dependencies +/*! + * Block below copied from Protovis: http://mbostock.github.com/protovis/ + * Copyright 2010 Stanford Visualization Group + * Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php + * @license + */ +if (!pv) { + var pv = { + map: function(array, f) { + var o = {}; + return f ? array.map(function(d, i) { o.index = i; return f.call(o, d); }) : array.slice(); + }, + naturalOrder: function(a, b) { + return (a < b) ? -1 : ((a > b) ? 1 : 0); + }, + sum: function(array, f) { + var o = {}; + return array.reduce(f ? function(p, d, i) { o.index = i; return p + f.call(o, d); } : function(p, d) { return p + d; }, 0); + }, + max: function(array, f) { + return Math.max.apply(null, f ? pv.map(array, f) : array); + } + }; +} + + + +/** + * Basic Javascript port of the MMCQ (modified median cut quantization) + * algorithm from the Leptonica library (http://www.leptonica.com/). + * Returns a color map you can use to map original pixels to the reduced + * palette. Still a work in progress. + * + * @author Nick Rabinowitz + * @example + +// array of pixels as [R,G,B] arrays +var myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207] + // etc + ]; +var maxColors = 4; + +var cmap = MMCQ.quantize(myPixels, maxColors); +var newPalette = cmap.palette(); +var newPixels = myPixels.map(function(p) { + return cmap.map(p); +}); + + */ +var MMCQ = (function() { + // private constants + var sigbits = 5, + rshift = 8 - sigbits, + maxIterations = 1000, + fractByPopulations = 0.75; + + // get reduced-space color index for a pixel + function getColorIndex(r, g, b) { + return (r << (2 * sigbits)) + (g << sigbits) + b; + } + + // Simple priority queue + function PQueue(comparator) { + var contents = [], + sorted = false; + + function sort() { + contents.sort(comparator); + sorted = true; + } + + return { + push: function(o) { + contents.push(o); + sorted = false; + }, + peek: function(index) { + if (!sorted) sort(); + if (index===undefined) index = contents.length - 1; + return contents[index]; + }, + pop: function() { + if (!sorted) sort(); + return contents.pop(); + }, + size: function() { + return contents.length; + }, + map: function(f) { + return contents.map(f); + }, + debug: function() { + if (!sorted) sort(); + return contents; + } + }; + } + + // 3d color space box + function VBox(r1, r2, g1, g2, b1, b2, histo) { + var vbox = this; + vbox.r1 = r1; + vbox.r2 = r2; + vbox.g1 = g1; + vbox.g2 = g2; + vbox.b1 = b1; + vbox.b2 = b2; + vbox.histo = histo; + } + VBox.prototype = { + volume: function(force) { + var vbox = this; + if (!vbox._volume || force) { + vbox._volume = ((vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1)); + } + return vbox._volume; + }, + count: function(force) { + var vbox = this, + histo = vbox.histo; + if (!vbox._count_set || force) { + var npix = 0, + index, i, j, k; + for (i = vbox.r1; i <= vbox.r2; i++) { + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(i,j,k); + npix += (histo[index] || 0); + } + } + } + vbox._count = npix; + vbox._count_set = true; + } + return vbox._count; + }, + copy: function() { + var vbox = this; + return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo); + }, + avg: function(force) { + var vbox = this, + histo = vbox.histo; + if (!vbox._avg || force) { + var ntot = 0, + mult = 1 << (8 - sigbits), + rsum = 0, + gsum = 0, + bsum = 0, + hval, + i, j, k, histoindex; + for (i = vbox.r1; i <= vbox.r2; i++) { + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + histoindex = getColorIndex(i,j,k); + hval = histo[histoindex] || 0; + ntot += hval; + rsum += (hval * (i + 0.5) * mult); + gsum += (hval * (j + 0.5) * mult); + bsum += (hval * (k + 0.5) * mult); + } + } + } + if (ntot) { + vbox._avg = [~~(rsum/ntot), ~~(gsum/ntot), ~~(bsum/ntot)]; + } else { + vbox._avg = [ + ~~(mult * (vbox.r1 + vbox.r2 + 1) / 2), + ~~(mult * (vbox.g1 + vbox.g2 + 1) / 2), + ~~(mult * (vbox.b1 + vbox.b2 + 1) / 2) + ]; + } + } + return vbox._avg; + }, + contains: function(pixel) { + var vbox = this, + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + return (rval >= vbox.r1 && rval <= vbox.r2 && + gval >= vbox.g1 && gval <= vbox.g2 && + bval >= vbox.b1 && bval <= vbox.b2); + } + }; + + // Color map + function CMap() { + this.vboxes = new PQueue(function(a,b) { + return pv.naturalOrder( + a.vbox.count()*a.vbox.volume(), + b.vbox.count()*b.vbox.volume() + ); + }); + } + CMap.prototype = { + push: function(vbox) { + this.vboxes.push({ + vbox: vbox, + color: vbox.avg() + }); + }, + palette: function() { + return this.vboxes.map(function(vb) { return vb.color; }); + }, + size: function() { + return this.vboxes.size(); + }, + map: function(color) { + var vboxes = this.vboxes; + for (var i=0; i 251 + var idx = vboxes.length-1, + highest = vboxes[idx].color; + if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251) + vboxes[idx].color = [255,255,255]; + } + }; + + // histo (1-d array, giving the number of pixels in + // each quantized region of color space), or null on error + function getHisto(pixels) { + var histosize = 1 << (3 * sigbits), + histo = new Array(histosize), + index, rval, gval, bval; + pixels.forEach(function(pixel) { + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + index = getColorIndex(rval, gval, bval); + histo[index] = (histo[index] || 0) + 1; + }); + return histo; + } + + function vboxFromPixels(pixels, histo) { + var rmin=1000000, rmax=0, + gmin=1000000, gmax=0, + bmin=1000000, bmax=0, + rval, gval, bval; + // find min/max + pixels.forEach(function(pixel) { + rval = pixel[0] >> rshift; + gval = pixel[1] >> rshift; + bval = pixel[2] >> rshift; + if (rval < rmin) rmin = rval; + else if (rval > rmax) rmax = rval; + if (gval < gmin) gmin = gval; + else if (gval > gmax) gmax = gval; + if (bval < bmin) bmin = bval; + else if (bval > bmax) bmax = bval; + }); + return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo); + } + + function medianCutApply(histo, vbox) { + if (!vbox.count()) return; + + var rw = vbox.r2 - vbox.r1 + 1, + gw = vbox.g2 - vbox.g1 + 1, + bw = vbox.b2 - vbox.b1 + 1, + maxw = pv.max([rw, gw, bw]); + // only one pixel, no split + if (vbox.count() == 1) { + return [vbox.copy()]; + } + /* Find the partial sum arrays along the selected axis. */ + var total = 0, + partialsum = [], + lookaheadsum = [], + i, j, k, sum, index; + if (maxw == rw) { + for (i = vbox.r1; i <= vbox.r2; i++) { + sum = 0; + for (j = vbox.g1; j <= vbox.g2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(i,j,k); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + else if (maxw == gw) { + for (i = vbox.g1; i <= vbox.g2; i++) { + sum = 0; + for (j = vbox.r1; j <= vbox.r2; j++) { + for (k = vbox.b1; k <= vbox.b2; k++) { + index = getColorIndex(j,i,k); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + else { /* maxw == bw */ + for (i = vbox.b1; i <= vbox.b2; i++) { + sum = 0; + for (j = vbox.r1; j <= vbox.r2; j++) { + for (k = vbox.g1; k <= vbox.g2; k++) { + index = getColorIndex(j,k,i); + sum += (histo[index] || 0); + } + } + total += sum; + partialsum[i] = total; + } + } + partialsum.forEach(function(d,i) { + lookaheadsum[i] = total-d; + }); + function doCut(color) { + var dim1 = color + '1', + dim2 = color + '2', + left, right, vbox1, vbox2, d2, count2=0; + for (i = vbox[dim1]; i <= vbox[dim2]; i++) { + if (partialsum[i] > total / 2) { + vbox1 = vbox.copy(); + vbox2 = vbox.copy(); + left = i - vbox[dim1]; + right = vbox[dim2] - i; + if (left <= right) + d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2)); + else d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2)); + // avoid 0-count boxes + while (!partialsum[d2]) d2++; + count2 = lookaheadsum[d2]; + while (!count2 && partialsum[d2-1]) count2 = lookaheadsum[--d2]; + // set dimensions + vbox1[dim2] = d2; + vbox2[dim1] = vbox1[dim2] + 1; + return [vbox1, vbox2]; + } + } + + } + // determine the cut planes + return maxw == rw ? doCut('r') : + maxw == gw ? doCut('g') : + doCut('b'); + } + + function quantize(pixels, maxcolors) { + // short-circuit + if (!pixels.length || maxcolors < 2 || maxcolors > 256) { + return false; + } + + // XXX: check color content and convert to grayscale if insufficient + + var histo = getHisto(pixels), + histosize = 1 << (3 * sigbits); + + // check that we aren't below maxcolors already + var nColors = 0; + histo.forEach(function() { nColors++; }); + if (nColors <= maxcolors) { + // XXX: generate the new colors from the histo and return + } + + // get the beginning vbox from the colors + var vbox = vboxFromPixels(pixels, histo), + pq = new PQueue(function(a,b) { return pv.naturalOrder(a.count(), b.count()); }); + pq.push(vbox); + + // inner function to do the iteration + function iter(lh, target) { + var ncolors = 1, + niters = 0, + vbox; + while (niters < maxIterations) { + vbox = lh.pop(); + if (!vbox.count()) { /* just put it back */ + lh.push(vbox); + niters++; + continue; + } + // do the cut + var vboxes = medianCutApply(histo, vbox), + vbox1 = vboxes[0], + vbox2 = vboxes[1]; + + if (!vbox1) { + return; + } + lh.push(vbox1); + if (vbox2) { /* vbox2 can be null */ + lh.push(vbox2); + ncolors++; + } + if (ncolors >= target) return; + if (niters++ > maxIterations) { + return; + } + } + } + + // first set of colors, sorted by population + iter(pq, fractByPopulations * maxcolors); + + // Re-sort by the product of pixel occupancy times the size in color space. + var pq2 = new PQueue(function(a,b) { + return pv.naturalOrder(a.count()*a.volume(), b.count()*b.volume()); + }); + while (pq.size()) { + pq2.push(pq.pop()); + } + + // next set - generate the median cuts using the (npix * vol) sorting. + iter(pq2, maxcolors - pq2.size()); + + // calculate the actual colors + var cmap = new CMap(); + while (pq2.size()) { + cmap.push(pq2.pop()); + } + + return cmap; + } + + return { + quantize: quantize + }; +})(); +export default ColorThief; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/color_utils.js b/paintplus/frontend/src/js/libs/color_utils.js new file mode 100644 index 0000000..40ab7ba --- /dev/null +++ b/paintplus/frontend/src/js/libs/color_utils.js @@ -0,0 +1,106 @@ +/** + * Color conversion and Pantone matching utilities. + * + * hexToRgb(hex) → { r, g, b } + * rgbToHsl(r,g,b) → { h, s, l } (h=0-360, s/l=0-100) + * rgbToLab(r,g,b) → { L, a, b } (CIE LAB D65) + * deltaE(lab1, lab2) → number (CIE76, lower = more similar) + * nearestPantone(hex) → { name, hex, deltaE } + */ + +import PANTONE_COLORS from './../data/pantone_colors.js'; + +// Pre-convert Pantone database to LAB once at module load +const _pantonelab = PANTONE_COLORS.map(([name, hex]) => { + const { r, g, b } = hexToRgb(hex); + return { name, hex, lab: rgbToLab(r, g, b) }; +}); + +export function hexToRgb(hex) { + const h = hex.replace('#', ''); + const n = parseInt(h.length === 3 + ? h.split('').map(c => c + c).join('') + : h, 16); + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; +} + +export function rgbToHex(r, g, b) { + return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); +} + +export function rgbToHsl(r, g, b) { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h, s, l = (max + min) / 2; + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break; + case g: h = ((b - r) / d + 2) / 6; break; + case b: h = ((r - g) / d + 4) / 6; break; + } + } + return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }; +} + +export function rgbToLab(r, g, b) { + // sRGB → linear + let R = r / 255, G = g / 255, B = b / 255; + R = R > 0.04045 ? Math.pow((R + 0.055) / 1.055, 2.4) : R / 12.92; + G = G > 0.04045 ? Math.pow((G + 0.055) / 1.055, 2.4) : G / 12.92; + B = B > 0.04045 ? Math.pow((B + 0.055) / 1.055, 2.4) : B / 12.92; + + // linear RGB → XYZ (D65) + let X = R * 0.4124564 + G * 0.3575761 + B * 0.1804375; + let Y = R * 0.2126729 + G * 0.7151522 + B * 0.0721750; + let Z = R * 0.0193339 + G * 0.1191920 + B * 0.9503041; + + // XYZ → LAB (D65 white = 0.95047, 1.0, 1.08883) + const f = v => v > 0.008856 ? Math.cbrt(v) : 7.787 * v + 16 / 116; + X = f(X / 0.95047); Y = f(Y / 1.0); Z = f(Z / 1.08883); + + return { L: 116 * Y - 16, a: 500 * (X - Y), b: 200 * (Y - Z) }; +} + +export function deltaE(lab1, lab2) { + const dL = lab1.L - lab2.L; + const da = lab1.a - lab2.a; + const db = lab1.b - lab2.b; + return Math.sqrt(dL * dL + da * da + db * db); +} + +/** + * Find the closest Pantone color to a hex value. + * Returns { name, hex, deltaE, quality } + * quality: 'excellent' (<2), 'good' (2-5), 'fair' (5-10), 'poor' (>10) + */ +export function nearestPantone(hex) { + const { r, g, b } = hexToRgb(hex); + const lab = rgbToLab(r, g, b); + + let best = null, bestDE = Infinity; + for (const entry of _pantonelab) { + const de = deltaE(lab, entry.lab); + if (de < bestDE) { bestDE = de; best = entry; } + } + + const de = Math.round(bestDE * 10) / 10; + const quality = de < 2 ? 'excellent' : de < 5 ? 'good' : de < 10 ? 'fair' : 'poor'; + return { name: best.name, hex: best.hex, deltaE: de, quality }; +} + +/** + * Quality label + color for ΔE badge. + */ +export function deltaEBadge(quality) { + const map = { + excellent: { label: 'Excellent match', color: '#4ade80' }, + good: { label: 'Good match', color: '#86efac' }, + fair: { label: 'Fair match', color: '#fbbf24' }, + poor: { label: 'Poor match — color may shift in print', color: '#f87171' }, + }; + return map[quality] || map.poor; +} diff --git a/paintplus/frontend/src/js/libs/gifjs/gif.js b/paintplus/frontend/src/js/libs/gifjs/gif.js new file mode 100644 index 0000000..f17a5c2 --- /dev/null +++ b/paintplus/frontend/src/js/libs/gifjs/gif.js @@ -0,0 +1,2 @@ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.GIF=e():t.GIF=e()}(this,function(){return function(t){function e(n){if(i[n])return i[n].exports;var r=i[n]={exports:{},id:n,loaded:!1};return t[n].call(r.exports,r,r.exports,e),r.loaded=!0,r.exports}var i={};return e.m=t,e.c=i,e.p="",e(0)}([function(t,e,i){var n,r,s,o=function(t,e){function i(){this.constructor=t}for(var n in e)a.call(e,n)&&(t[n]=e[n]);return i.prototype=e.prototype,t.prototype=new i,t.__super__=e.prototype,t},a={}.hasOwnProperty,h=[].indexOf||function(t){for(var e=0,i=this.length;e0&&i.data&&(this.groups.has(i.data)?this.groups.get(i.data).push(r):this.groups.set(i.data,[r])),this.frames.push(i)},e.prototype.render=function(){var t,e,i,n;if(this.running)throw new Error("Already running");if(null==this.options.width||null==this.options.height)throw new Error("Width and height must be set prior to rendering");if(this.running=!0,this.nextFrame=0,this.finishedFrames=0,this.imageParts=function(){var e,i,n;for(n=[],t=e=0,i=this.frames.length;0<=i?ei;t=0<=i?++e:--e)n.push(null);return n}.call(this),i=this.spawnWorkers(),this.options.globalPalette===!0)this.renderNextFrame();else for(t=e=0,n=i;0<=n?en;t=0<=n?++e:--e)this.renderNextFrame();return this.emit("start"),this.emit("progress",0)},e.prototype.abort=function(){for(var t;;){if(t=this.activeWorkers.shift(),null==t)break;this.log("killing active worker"),t.terminate()}return this.running=!1,this.emit("abort")},e.prototype.spawnWorkers=function(){var t,e,i;return t=Math.min(this.options.workers,this.frames.length),function(){i=[];for(var n=e=this.freeWorkers.length;e<=t?nt;e<=t?n++:n--)i.push(n);return i}.apply(this).forEach(function(t){return function(e){var i;return t.log("spawning worker "+e),i=new Worker(t.options.workerScript),i.onmessage=function(e){return t.activeWorkers.splice(t.activeWorkers.indexOf(i),1),t.freeWorkers.push(i),t.frameFinished(e.data,!1)},t.freeWorkers.push(i)}}(this)),t},e.prototype.frameFinished=function(t,e){var i,n,r,s,o;if(this.finishedFrames++,e?(n=this.frames.indexOf(t),r=this.groups.get(t.data)[0],this.log("frame "+(n+1)+" is duplicate of "+r+" - "+this.activeWorkers.length+" active"),this.imageParts[n]={indexOfFirstInGroup:r}):(this.log("frame "+(t.index+1)+" finished - "+this.activeWorkers.length+" active"),this.emit("progress",this.finishedFrames/this.frames.length),this.imageParts[t.index]=t),this.options.globalPalette===!0&&!e&&(this.options.globalPalette=t.globalPalette,this.log("global palette analyzed"),this.frames.length>2))for(i=s=1,o=this.freeWorkers.length;1<=o?so;i=1<=o?++s:--s)this.renderNextFrame();return h.call(this.imageParts,null)>=0?this.renderNextFrame():this.finishRendering()},e.prototype.finishRendering=function(){var t,e,i,n,r,s,o,a,h,l,f,p,u,d,c,g,v,m,y,_;for(v=this.imageParts,r=s=0,l=v.length;s=this.frames.length))return t=this.frames[this.nextFrame++],e=this.frames.indexOf(t),e>0&&this.groups.has(t.data)&&this.groups.get(t.data)[0]!==e?void setTimeout(function(e){return function(){return e.frameFinished(t,!0)}}(this),0):(n=this.freeWorkers.shift(),i=this.getTask(t),this.log("starting frame "+(i.index+1)+" of "+this.frames.length),this.activeWorkers.push(n),n.postMessage(i))},e.prototype.getContextData=function(t){return t.getImageData(0,0,this.options.width,this.options.height).data},e.prototype.getImageData=function(t){var e;return null==this._canvas&&(this._canvas=document.createElement("canvas"),this._canvas.width=this.options.width,this._canvas.height=this.options.height),e=this._canvas.getContext("2d"),e.setFill=this.options.background,e.fillRect(0,0,this.options.width,this.options.height),e.drawImage(t,0,0),this.getContextData(e)},e.prototype.getTask=function(t){var e,i;if(e=this.frames.indexOf(t),i={index:e,last:e===this.frames.length-1,delay:t.delay,transparent:t.transparent,width:this.options.width,height:this.options.height,quality:this.options.quality,dither:this.options.dither,globalPalette:this.options.globalPalette,repeat:this.options.repeat,canTransfer:!0},null!=t.data)i.data=t.data;else if(null!=t.context)i.data=this.getContextData(t.context);else{if(null==t.image)throw new Error("Invalid frame");i.data=this.getImageData(t.image)}return i},e.prototype.log=function(t){if(this.options.debug)return console.log(t)},e}(n),t.exports=r},function(t,e){function i(){this._events=this._events||{},this._maxListeners=this._maxListeners||void 0}function n(t){return"function"==typeof t}function r(t){return"number"==typeof t}function s(t){return"object"==typeof t&&null!==t}function o(t){return void 0===t}t.exports=i,i.EventEmitter=i,i.prototype._events=void 0,i.prototype._maxListeners=void 0,i.defaultMaxListeners=10,i.prototype.setMaxListeners=function(t){if(!r(t)||t<0||isNaN(t))throw TypeError("n must be a positive number");return this._maxListeners=t,this},i.prototype.emit=function(t){var e,i,r,a,h,l;if(this._events||(this._events={}),"error"===t&&(!this._events.error||s(this._events.error)&&!this._events.error.length)){if(e=arguments[1],e instanceof Error)throw e;var f=new Error('Uncaught, unspecified "error" event. ('+e+")");throw f.context=e,f}if(i=this._events[t],o(i))return!1;if(n(i))switch(arguments.length){case 1:i.call(this);break;case 2:i.call(this,arguments[1]);break;case 3:i.call(this,arguments[1],arguments[2]);break;default:a=Array.prototype.slice.call(arguments,1),i.apply(this,a)}else if(s(i))for(a=Array.prototype.slice.call(arguments,1),l=i.slice(),r=l.length,h=0;h0&&this._events[t].length>r&&(this._events[t].warned=!0,console.error("(node) warning: possible EventEmitter memory leak detected. %d listeners added. Use emitter.setMaxListeners() to increase limit.",this._events[t].length),"function"==typeof console.trace&&console.trace())),this},i.prototype.on=i.prototype.addListener,i.prototype.once=function(t,e){function i(){this.removeListener(t,i),r||(r=!0,e.apply(this,arguments))}if(!n(e))throw TypeError("listener must be a function");var r=!1;return i.listener=e,this.on(t,i),this},i.prototype.removeListener=function(t,e){var i,r,o,a;if(!n(e))throw TypeError("listener must be a function");if(!this._events||!this._events[t])return this;if(i=this._events[t],o=i.length,r=-1,i===e||n(i.listener)&&i.listener===e)delete this._events[t],this._events.removeListener&&this.emit("removeListener",t,e);else if(s(i)){for(a=o;a-- >0;)if(i[a]===e||i[a].listener&&i[a].listener===e){r=a;break}if(r<0)return this;1===i.length?(i.length=0,delete this._events[t]):i.splice(r,1),this._events.removeListener&&this.emit("removeListener",t,e)}return this},i.prototype.removeAllListeners=function(t){var e,i;if(!this._events)return this;if(!this._events.removeListener)return 0===arguments.length?this._events={}:this._events[t]&&delete this._events[t],this;if(0===arguments.length){for(e in this._events)"removeListener"!==e&&this.removeAllListeners(e);return this.removeAllListeners("removeListener"),this._events={},this}if(i=this._events[t],n(i))this.removeListener(t,i);else if(i)for(;i.length;)this.removeListener(t,i[i.length-1]);return delete this._events[t],this},i.prototype.listeners=function(t){var e;return e=this._events&&this._events[t]?n(this._events[t])?[this._events[t]]:this._events[t].slice():[]},i.prototype.listenerCount=function(t){if(this._events){var e=this._events[t];if(n(e))return 1;if(e)return e.length}return 0},i.listenerCount=function(t,e){return t.listenerCount(e)}},function(t,e){var i,n,r,s,o;o=navigator.userAgent.toLowerCase(),s=navigator.platform.toLowerCase(),i=o.match(/(opera|ie|firefox|chrome|version)[\s\/:]([\w\d\.]+)?.*?(safari|version[\s\/:]([\w\d\.]+)|$)/)||[null,"unknown",0],r="ie"===i[1]&&document.documentMode,n={name:"version"===i[1]?i[3]:i[1],version:r||parseFloat("opera"===i[1]&&i[4]?i[4]:i[2]),platform:{name:o.match(/ip(?:ad|od|hone)/)?"ios":(o.match(/(?:webos|android)/)||s.match(/mac|win|linux/)||["other"])[0]}},n[n.name]=!0,n[n.name+parseInt(n.version,10)]=!0,n.platform[n.platform.name]=!0,t.exports=n}])}); +//# sourceMappingURL=gif.js.map \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/gifjs/gif.worker.js b/paintplus/frontend/src/js/libs/gifjs/gif.worker.js new file mode 100644 index 0000000..28b1f18 --- /dev/null +++ b/paintplus/frontend/src/js/libs/gifjs/gif.worker.js @@ -0,0 +1,2 @@ +!function(t){function e(r){if(i[r])return i[r].exports;var s=i[r]={exports:{},id:r,loaded:!1};return t[r].call(s.exports,s,s.exports,e),s.loaded=!0,s.exports}var i={};return e.m=t,e.c=i,e.p="",e(0)}([function(t,e,i){var r,s;r=i(1),s=function(t){var e,i,s,o;return e=new r(t.width,t.height),0===t.index?e.writeHeader():e.firstFrame=!1,e.setTransparent(t.transparent),e.setRepeat(t.repeat),e.setDelay(t.delay),e.setQuality(t.quality),e.setDither(t.dither),e.setGlobalPalette(t.globalPalette),e.addFrame(t.data),t.last&&e.finish(),t.globalPalette===!0&&(t.globalPalette=e.getGlobalPalette()),s=e.stream(),t.data=s.pages,t.cursor=s.cursor,t.pageSize=s.constructor.pageSize,t.canTransfer?(o=function(){var e,r,s,o;for(s=t.data,o=[],e=0,r=s.length;e=r.pageSize&&this.newPage(),this.pages[this.page][this.cursor++]=t},r.prototype.writeUTFBytes=function(t){for(var e=t.length,i=0;i=0&&(this.dispose=t)},s.prototype.setRepeat=function(t){this.repeat=t},s.prototype.setTransparent=function(t){this.transparent=t},s.prototype.addFrame=function(t){this.image=t,this.colorTab=this.globalPalette&&this.globalPalette.slice?this.globalPalette:null,this.getImagePixels(),this.analyzePixels(),this.globalPalette===!0&&(this.globalPalette=this.colorTab),this.firstFrame&&(this.writeLSD(),this.writePalette(),this.repeat>=0&&this.writeNetscapeExt()),this.writeGraphicCtrlExt(),this.writeImageDesc(),this.firstFrame||this.globalPalette||this.writePalette(),this.writePixels(),this.firstFrame=!1},s.prototype.finish=function(){this.out.writeByte(59)},s.prototype.setQuality=function(t){t<1&&(t=1),this.sample=t},s.prototype.setDither=function(t){t===!0&&(t="FloydSteinberg"),this.dither=t},s.prototype.setGlobalPalette=function(t){this.globalPalette=t},s.prototype.getGlobalPalette=function(){return this.globalPalette&&this.globalPalette.slice&&this.globalPalette.slice(0)||this.globalPalette},s.prototype.writeHeader=function(){this.out.writeUTFBytes("GIF89a")},s.prototype.analyzePixels=function(){this.colorTab||(this.neuQuant=new o(this.pixels,this.sample),this.neuQuant.buildColormap(),this.colorTab=this.neuQuant.getColormap()),this.dither?this.ditherPixels(this.dither.replace("-serpentine",""),null!==this.dither.match(/-serpentine/)):this.indexPixels(),this.pixels=null,this.colorDepth=8,this.palSize=7,null!==this.transparent&&(this.transIndex=this.findClosest(this.transparent,!0))},s.prototype.indexPixels=function(){var t=this.pixels.length/3;this.indexedPixels=new Uint8Array(t);for(var e=0,i=0;i=0&&S+u=0&&T+l>16,(65280&t)>>8,255&t,e)},s.prototype.findClosestRGB=function(t,e,i,r){if(null===this.colorTab)return-1;if(this.neuQuant&&!r)return this.neuQuant.lookupRGB(t,e,i);for(var s=0,o=16777216,n=this.colorTab.length,a=0,h=0;a=0&&(e=7&dispose),e<<=2,this.out.writeByte(0|e|0|t),this.writeShort(this.delay),this.out.writeByte(this.transIndex),this.out.writeByte(0)},s.prototype.writeImageDesc=function(){this.out.writeByte(44),this.writeShort(0),this.writeShort(0),this.writeShort(this.width),this.writeShort(this.height),this.firstFrame||this.globalPalette?this.out.writeByte(0):this.out.writeByte(128|this.palSize)},s.prototype.writeLSD=function(){this.writeShort(this.width),this.writeShort(this.height),this.out.writeByte(240|this.palSize),this.out.writeByte(0),this.out.writeByte(0)},s.prototype.writeNetscapeExt=function(){this.out.writeByte(33),this.out.writeByte(255),this.out.writeByte(11),this.out.writeUTFBytes("NETSCAPE2.0"),this.out.writeByte(3),this.out.writeByte(1),this.writeShort(this.repeat),this.out.writeByte(0)},s.prototype.writePalette=function(){this.out.writeBytes(this.colorTab);for(var t=768-this.colorTab.length,e=0;e>8&255)},s.prototype.writePixels=function(){var t=new n(this.width,this.height,this.indexedPixels,this.colorDepth);t.encode(this.out)},s.prototype.stream=function(){return this.out},t.exports=s},function(t,e){function i(t,e){function i(){z=[],E=new Int32Array(256),R=new Int32Array(s),U=new Int32Array(s),Q=new Int32Array(s>>3);var t,e;for(t=0;t>=n,z[t][1]>>=n,z[t][2]>>=n,z[t][3]=t}function w(t,e,i,r,s){z[e][0]-=t*(z[e][0]-i)/b,z[e][1]-=t*(z[e][1]-r)/b,z[e][2]-=t*(z[e][2]-s)/b}function x(t,e,i,r,o){for(var n,a,h=Math.abs(e-t),l=Math.min(e+t,s),u=e+1,p=e-1,f=1;uh;)a=Q[f++],uh&&(n=z[p--],n[0]-=a*(n[0]-i)/B,n[1]-=a*(n[1]-r)/B,n[2]-=a*(n[2]-o)/B)}function v(t,e,i){t=0|t,e=0|e,i=0|i;var r,o,h,c,y,w=~(1<<31),d=w,g=-1,x=g;for(r=0;r>a-n),c>u,U[r]-=y,R[r]+=y<>1,e=h+1;e>1,e=h+1;e<256;e++)E[e]=o}function C(t,e,i){t=0|t,e=0|e,i=0|i;for(var r,o,n,a=1e3,h=-1,l=0|E[e],u=l-1;l=0;)l=a?l=s:(l++,n<0&&(n=-n),r=(0|o[0])-t,r<0&&(r=-r),n+=r,n=0&&(o=z[u],n=e-(0|o[1]),n>=a?u=-1:(u--,n<0&&(n=-n),r=(0|o[0])-t,r<0&&(r=-r),n+=r,n>y;for(p<=1&&(p=0),i=0;i=s&&(I-=s),i++,0===h&&(h=1),i%h===0)for(l-=l/o,u-=u/g,p=u>>y,p<=1&&(p=0),C=0;C>u,f=h<>3,y=6,w=1<=254&&c(e)}function l(t){u(o),A=P+2,C=!0,d(P,t)}function u(t){for(var e=0;e=0){p=f-a,0===a&&(p=1);do if((a-=p)<0&&(a+=f),T[a]===i){h=M[a];continue t}while(T[a]>=0)}d(h,e),h=n,A<1<0&&(t.writeByte(x),t.writeBytes(S,0,x),x=0)}function y(t){return(1<0?g|=t<=8;)h(255&g,e),g>>=8,F-=8;if((A>b||C)&&(C?(b=y(n_bits=v),C=!1):(++n_bits,b=n_bits==s?1<0;)h(255&g,e),g>>=8,F-=8;c(e)}}var g,x,b,v,P,m,B=Math.max(2,a),S=new Uint8Array(256),T=new Int32Array(o),M=new Int32Array(o),F=0,A=0,C=!1;this.encode=f}var r=-1,s=12,o=5003,n=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];t.exports=i}]); +//# sourceMappingURL=gif.worker.js.map \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/glfx.js b/paintplus/frontend/src/js/libs/glfx.js new file mode 100644 index 0000000..61022d8 --- /dev/null +++ b/paintplus/frontend/src/js/libs/glfx.js @@ -0,0 +1,61 @@ +/* + * glfx.js + * http://evanw.github.com/glfx.js/ + * + * Copyright 2011 Evan Wallace + * Released under the MIT license + */ +var fx=function(){function q(a,d,c){return Math.max(a,Math.min(d,c))}function w(b){return{_:b,loadContentsOf:function(b){a=this._.gl;this._.loadContentsOf(b)},destroy:function(){a=this._.gl;this._.destroy()}}}function A(a){return w(r.fromElement(a))}function B(b,d){var c=a.UNSIGNED_BYTE;if(a.getExtension("OES_texture_float")&&a.getExtension("OES_texture_float_linear")){var e=new r(100,100,a.RGBA,a.FLOAT);try{e.drawTo(function(){c=a.FLOAT})}catch(g){}e.destroy()}this._.texture&&this._.texture.destroy(); +this._.spareTexture&&this._.spareTexture.destroy();this.width=b;this.height=d;this._.texture=new r(b,d,a.RGBA,c);this._.spareTexture=new r(b,d,a.RGBA,c);this._.extraTexture=this._.extraTexture||new r(0,0,a.RGBA,c);this._.flippedShader=this._.flippedShader||new h(null,"uniform sampler2D texture;varying vec2 texCoord;void main(){gl_FragColor=texture2D(texture,vec2(texCoord.x,1.0-texCoord.y));}");this._.isInitialized=!0}function C(a,d,c){this._.isInitialized&& +a._.width==this.width&&a._.height==this.height||B.call(this,d?d:a._.width,c?c:a._.height);a._.use();this._.texture.drawTo(function(){h.getDefaultShader().drawRect()});return this}function D(){this._.texture.use();this._.flippedShader.drawRect();return this}function f(a,d,c,e){(c||this._.texture).use();this._.spareTexture.drawTo(function(){a.uniforms(d).drawRect()});this._.spareTexture.swapWith(e||this._.texture)}function E(a){a.parentNode.insertBefore(this,a);a.parentNode.removeChild(a);return this} +function F(){var b=new r(this._.texture.width,this._.texture.height,a.RGBA,a.UNSIGNED_BYTE);this._.texture.use();b.drawTo(function(){h.getDefaultShader().drawRect()});return w(b)}function G(){var b=this._.texture.width,d=this._.texture.height,c=new Uint8Array(4*b*d);this._.texture.drawTo(function(){a.readPixels(0,0,b,d,a.RGBA,a.UNSIGNED_BYTE,c)});return c}function k(b){return function(){a=this._.gl;return b.apply(this,arguments)}}function x(a,d,c,e,g,l,n,p){var m=c-g,h=e-l,f=n-g,k=p-l;g=a-c+g-n;l= +d-e+l-p;var q=m*k-f*h,f=(g*k-f*l)/q,m=(m*l-g*h)/q;return[c-a+f*c,e-d+f*e,f,n-a+m*n,p-d+m*p,m,a,d,1]}function y(a){var d=a[0],c=a[1],e=a[2],g=a[3],l=a[4],n=a[5],p=a[6],m=a[7];a=a[8];var f=d*l*a-d*n*m-c*g*a+c*n*p+e*g*m-e*l*p;return[(l*a-n*m)/f,(e*m-c*a)/f,(c*n-e*l)/f,(n*p-g*a)/f,(d*a-e*p)/f,(e*g-d*n)/f,(g*m-l*p)/f,(c*p-d*m)/f,(d*l-c*g)/f]}function z(a){var d=a.length;this.xa=[];this.ya=[];this.u=[];this.y2=[];a.sort(function(a,b){return a[0]-b[0]});for(var c=0;c0.0){color.rgb=(color.rgb-0.5)/(1.0-contrast)+0.5;}else{color.rgb=(color.rgb-0.5)*(1.0+contrast)+0.5;}gl_FragColor=color;}"); +f.call(this,a.brightnessContrast,{brightness:q(-1,b,1),contrast:q(-1,d,1)});return this}function t(a){a=new z(a);for(var d=[],c=0;256>c;c++)d.push(q(0,Math.floor(256*a.interpolate(c/255)),255));return d}function I(b,d,c){b=t(b);1==arguments.length?d=c=b:(d=t(d),c=t(c));for(var e=[],g=0;256>g;g++)e.splice(e.length,0,b[g],d[g],c[g],255);this._.extraTexture.initFromBytes(256,1,e);this._.extraTexture.use(1);a.curves=a.curves||new h(null,"uniform sampler2D texture;uniform sampler2D map;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);color.r=texture2D(map,vec2(color.r)).r;color.g=texture2D(map,vec2(color.g)).g;color.b=texture2D(map,vec2(color.b)).b;gl_FragColor=color;}"); +a.curves.textures({map:1});f.call(this,a.curves,{});return this}function J(b){a.denoise=a.denoise||new h(null,"uniform sampler2D texture;uniform float exponent;uniform float strength;uniform vec2 texSize;varying vec2 texCoord;void main(){vec4 center=texture2D(texture,texCoord);vec4 color=vec4(0.0);float total=0.0;for(float x=-4.0;x<=4.0;x+=1.0){for(float y=-4.0;y<=4.0;y+=1.0){vec4 sample=texture2D(texture,texCoord+vec2(x,y)/texSize);float weight=1.0-abs(dot(sample.rgb-center.rgb,vec3(0.25)));weight=pow(weight,exponent);color+=sample*weight;total+=weight;}}gl_FragColor=color/total;}"); +for(var d=0;2>d;d++)f.call(this,a.denoise,{exponent:Math.max(0,b),texSize:[this.width,this.height]});return this}function K(b,d){a.hueSaturation=a.hueSaturation||new h(null,"uniform sampler2D texture;uniform float hue;uniform float saturation;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float angle=hue*3.14159265;float s=sin(angle),c=cos(angle);vec3 weights=(vec3(2.0*c,-sqrt(3.0)*s-c,sqrt(3.0)*s-c)+1.0)/3.0;float len=length(color.rgb);color.rgb=vec3(dot(color.rgb,weights.xyz),dot(color.rgb,weights.zxy),dot(color.rgb,weights.yzx));float average=(color.r+color.g+color.b)/3.0;if(saturation>0.0){color.rgb+=(average-color.rgb)*(1.0-1.0/(1.001-saturation));}else{color.rgb+=(average-color.rgb)*(-saturation);}gl_FragColor=color;}"); +f.call(this,a.hueSaturation,{hue:q(-1,b,1),saturation:q(-1,d,1)});return this}function L(b){a.noise=a.noise||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;float rand(vec2 co){return fract(sin(dot(co.xy,vec2(12.9898,78.233)))*43758.5453);}void main(){vec4 color=texture2D(texture,texCoord);float diff=(rand(texCoord)-0.5)*amount;color.r+=diff;color.g+=diff;color.b+=diff;gl_FragColor=color;}"); +f.call(this,a.noise,{amount:q(0,b,1)});return this}function M(b){a.sepia=a.sepia||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float r=color.r;float g=color.g;float b=color.b;color.r=min(1.0,(r*(1.0-(0.607*amount)))+(g*(0.769*amount))+(b*(0.189*amount)));color.g=min(1.0,(r*0.349*amount)+(g*(1.0-(0.314*amount)))+(b*0.168*amount));color.b=min(1.0,(r*0.272*amount)+(g*0.534*amount)+(b*(1.0-(0.869*amount))));gl_FragColor=color;}"); +f.call(this,a.sepia,{amount:q(0,b,1)});return this}function N(b,d){a.unsharpMask=a.unsharpMask||new h(null,"uniform sampler2D blurredTexture;uniform sampler2D originalTexture;uniform float strength;uniform float threshold;varying vec2 texCoord;void main(){vec4 blurred=texture2D(blurredTexture,texCoord);vec4 original=texture2D(originalTexture,texCoord);gl_FragColor=mix(blurred,original,1.0+strength);}"); +this._.extraTexture.ensureFormat(this._.texture);this._.texture.use();this._.extraTexture.drawTo(function(){h.getDefaultShader().drawRect()});this._.extraTexture.use(1);this.triangleBlur(b);a.unsharpMask.textures({originalTexture:1});f.call(this,a.unsharpMask,{strength:d});this._.extraTexture.unuse(1);return this}function O(b){a.vibrance=a.vibrance||new h(null,"uniform sampler2D texture;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float average=(color.r+color.g+color.b)/3.0;float mx=max(color.r,max(color.g,color.b));float amt=(mx-average)*(-amount*3.0);color.rgb=mix(color.rgb,vec3(mx),amt);gl_FragColor=color;}"); +f.call(this,a.vibrance,{amount:q(-1,b,1)});return this}function P(b,d){a.vignette=a.vignette||new h(null,"uniform sampler2D texture;uniform float size;uniform float amount;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);float dist=distance(texCoord,vec2(0.5,0.5));color.rgb*=smoothstep(0.8,size*0.799,dist*(amount+size));gl_FragColor=color;}"); +f.call(this,a.vignette,{size:q(0,b,1),amount:q(0,d,1)});return this}function Q(b,d,c){a.lensBlurPrePass=a.lensBlurPrePass||new h(null,"uniform sampler2D texture;uniform float power;varying vec2 texCoord;void main(){vec4 color=texture2D(texture,texCoord);color=pow(color,vec4(power));gl_FragColor=vec4(color);}");var e="uniform sampler2D texture0;uniform sampler2D texture1;uniform vec2 delta0;uniform vec2 delta1;uniform float power;varying vec2 texCoord;"+ +s+"vec4 sample(vec2 delta){float offset=random(vec3(delta,151.7182),0.0);vec4 color=vec4(0.0);float total=0.0;for(float t=0.0;t<=30.0;t++){float percent=(t+offset)/30.0;color+=texture2D(texture0,texCoord+delta*percent);total+=1.0;}return color/total;}"; +a.lensBlur0=a.lensBlur0||new h(null,e+"void main(){gl_FragColor=sample(delta0);}");a.lensBlur1=a.lensBlur1||new h(null,e+"void main(){gl_FragColor=(sample(delta0)+sample(delta1))*0.5;}");a.lensBlur2=a.lensBlur2||(new h(null,e+"void main(){vec4 color=(sample(delta0)+2.0*texture2D(texture1,texCoord))/3.0;gl_FragColor=pow(color,vec4(power));}")).textures({texture1:1});for(var e= +[],g=0;3>g;g++){var l=c+2*g*Math.PI/3;e.push([b*Math.sin(l)/this.width,b*Math.cos(l)/this.height])}b=Math.pow(10,q(-1,d,1));f.call(this,a.lensBlurPrePass,{power:b});this._.extraTexture.ensureFormat(this._.texture);f.call(this,a.lensBlur0,{delta0:e[0]},this._.texture,this._.extraTexture);f.call(this,a.lensBlur1,{delta0:e[1],delta1:e[2]},this._.extraTexture,this._.extraTexture);f.call(this,a.lensBlur0,{delta0:e[1]});this._.extraTexture.use(1);f.call(this,a.lensBlur2,{power:1/b,delta0:e[2]});return this} +function R(b,d,c,e,g,l){a.tiltShift=a.tiltShift||new h(null,"uniform sampler2D texture;uniform float blurRadius;uniform float gradientRadius;uniform vec2 start;uniform vec2 end;uniform vec2 delta;uniform vec2 texSize;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;float offset=random(vec3(12.9898,78.233,151.7182),0.0);vec2 normal=normalize(vec2(start.y-end.y,end.x-start.x));float radius=smoothstep(0.0,1.0,abs(dot(texCoord*texSize-start,normal))/gradientRadius)*blurRadius;for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec4 sample=texture2D(texture,texCoord+delta/texSize*percent*radius);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}"); +var n=c-b,p=e-d,m=Math.sqrt(n*n+p*p);f.call(this,a.tiltShift,{blurRadius:g,gradientRadius:l,start:[b,d],end:[c,e],delta:[n/m,p/m],texSize:[this.width,this.height]});f.call(this,a.tiltShift,{blurRadius:g,gradientRadius:l,start:[b,d],end:[c,e],delta:[-p/m,n/m],texSize:[this.width,this.height]});return this}function S(b){a.triangleBlur=a.triangleBlur||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec4 sample=texture2D(texture,texCoord+delta*percent);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}"); +f.call(this,a.triangleBlur,{delta:[b/this.width,0]});f.call(this,a.triangleBlur,{delta:[0,b/this.height]});return this}function T(b,d,c){a.zoomBlur=a.zoomBlur||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float strength;uniform vec2 texSize;varying vec2 texCoord;"+s+"void main(){vec4 color=vec4(0.0);float total=0.0;vec2 toCenter=center-texCoord*texSize;float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=0.0;t<=40.0;t++){float percent=(t+offset)/40.0;float weight=4.0*(percent-percent*percent);vec4 sample=texture2D(texture,texCoord+toCenter*percent*strength/texSize);sample.rgb*=sample.a;color+=sample*weight;total+=weight;}gl_FragColor=color/total;gl_FragColor.rgb/=gl_FragColor.a+0.00001;}"); +f.call(this,a.zoomBlur,{center:[b,d],strength:c,texSize:[this.width,this.height]});return this}function U(b,d,c,e){a.colorHalftone=a.colorHalftone||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float angle;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;float pattern(float angle){float s=sin(angle),c=cos(angle);vec2 tex=texCoord*texSize-center;vec2 point=vec2(c*tex.x-s*tex.y,s*tex.x+c*tex.y)*scale;return(sin(point.x)*sin(point.y))*4.0;}void main(){vec4 color=texture2D(texture,texCoord);vec3 cmy=1.0-color.rgb;float k=min(cmy.x,min(cmy.y,cmy.z));cmy=(cmy-k)/(1.0-k);cmy=clamp(cmy*10.0-3.0+vec3(pattern(angle+0.26179),pattern(angle+1.30899),pattern(angle)),0.0,1.0);k=clamp(k*10.0-5.0+pattern(angle+0.78539),0.0,1.0);gl_FragColor=vec4(1.0-cmy-k,color.a);}"); +f.call(this,a.colorHalftone,{center:[b,d],angle:c,scale:Math.PI/e,texSize:[this.width,this.height]});return this}function V(b,d,c,e){a.dotScreen=a.dotScreen||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float angle;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;float pattern(){float s=sin(angle),c=cos(angle);vec2 tex=texCoord*texSize-center;vec2 point=vec2(c*tex.x-s*tex.y,s*tex.x+c*tex.y)*scale;return(sin(point.x)*sin(point.y))*4.0;}void main(){vec4 color=texture2D(texture,texCoord);float average=(color.r+color.g+color.b)/3.0;gl_FragColor=vec4(vec3(average*10.0-5.0+pattern()),color.a);}"); +f.call(this,a.dotScreen,{center:[b,d],angle:c,scale:Math.PI/e,texSize:[this.width,this.height]});return this}function W(b){a.edgeWork1=a.edgeWork1||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec2 color=vec2(0.0);vec2 total=vec2(0.0);float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec3 sample=texture2D(texture,texCoord+delta*percent).rgb;float average=(sample.r+sample.g+sample.b)/3.0;color.x+=average*weight;total.x+=weight;if(abs(t)<15.0){weight=weight*2.0-1.0;color.y+=average*weight;total.y+=weight;}}gl_FragColor=vec4(color/total,0.0,1.0);}"); +a.edgeWork2=a.edgeWork2||new h(null,"uniform sampler2D texture;uniform vec2 delta;varying vec2 texCoord;"+s+"void main(){vec2 color=vec2(0.0);vec2 total=vec2(0.0);float offset=random(vec3(12.9898,78.233,151.7182),0.0);for(float t=-30.0;t<=30.0;t++){float percent=(t+offset-0.5)/30.0;float weight=1.0-abs(percent);vec2 sample=texture2D(texture,texCoord+delta*percent).xy;color.x+=sample.x*weight;total.x+=weight;if(abs(t)<15.0){weight=weight*2.0-1.0;color.y+=sample.y*weight;total.y+=weight;}}float c=clamp(10000.0*(color.y/total.y-color.x/total.x)+0.5,0.0,1.0);gl_FragColor=vec4(c,c,c,1.0);}"); +f.call(this,a.edgeWork1,{delta:[b/this.width,0]});f.call(this,a.edgeWork2,{delta:[0,b/this.height]});return this}function X(b,d,c){a.hexagonalPixelate=a.hexagonalPixelate||new h(null,"uniform sampler2D texture;uniform vec2 center;uniform float scale;uniform vec2 texSize;varying vec2 texCoord;void main(){vec2 tex=(texCoord*texSize-center)/scale;tex.y/=0.866025404;tex.x-=tex.y*0.5;vec2 a;if(tex.x+tex.y-floor(tex.x)-floor(tex.y)<1.0)a=vec2(floor(tex.x),floor(tex.y));else a=vec2(ceil(tex.x),ceil(tex.y));vec2 b=vec2(ceil(tex.x),floor(tex.y));vec2 c=vec2(floor(tex.x),ceil(tex.y));vec3 TEX=vec3(tex.x,tex.y,1.0-tex.x-tex.y);vec3 A=vec3(a.x,a.y,1.0-a.x-a.y);vec3 B=vec3(b.x,b.y,1.0-b.x-b.y);vec3 C=vec3(c.x,c.y,1.0-c.x-c.y);float alen=length(TEX-A);float blen=length(TEX-B);float clen=length(TEX-C);vec2 choice;if(alen0.0){coord*=mix(1.0,smoothstep(0.0,radius/distance,percent),strength*0.75);}else{coord*=mix(1.0,pow(percent,1.0+strength*0.75)*radius/distance,1.0-percent);}}coord+=center;"); +f.call(this,a.bulgePinch,{radius:c,strength:q(-1,e,1),center:[b,d],texSize:[this.width,this.height]});return this}function $(b,d,c){a.matrixWarp=a.matrixWarp||u("uniform mat3 matrix;uniform bool useTextureSpace;","if(useTextureSpace)coord=coord/texSize*2.0-1.0;vec3 warp=matrix*vec3(coord,1.0);coord=warp.xy/warp.z;if(useTextureSpace)coord=(coord*0.5+0.5)*texSize;");b=Array.prototype.concat.apply([],b);if(4==b.length)b= +[b[0],b[1],0,b[2],b[3],0,0,0,1];else if(9!=b.length)throw"can only warp with 2x2 or 3x3 matrix";f.call(this,a.matrixWarp,{matrix:d?y(b):b,texSize:[this.width,this.height],useTextureSpace:c|0});return this}function aa(a,d){var c=x.apply(null,d),e=x.apply(null,a),c=y(c);return this.matrixWarp([c[0]*e[0]+c[1]*e[3]+c[2]*e[6],c[0]*e[1]+c[1]*e[4]+c[2]*e[7],c[0]*e[2]+c[1]*e[5]+c[2]*e[8],c[3]*e[0]+c[4]*e[3]+c[5]*e[6],c[3]*e[1]+c[4]*e[4]+c[5]*e[7],c[3]*e[2]+c[4]*e[5]+c[5]*e[8],c[6]*e[0]+c[7]*e[3]+c[8]*e[6], +c[6]*e[1]+c[7]*e[4]+c[8]*e[7],c[6]*e[2]+c[7]*e[5]+c[8]*e[8]])}function ba(b,d,c,e){a.swirl=a.swirl||u("uniform float radius;uniform float angle;uniform vec2 center;","coord-=center;float distance=length(coord);if(distance>1;this.xa[e]>a?c=e:d=e}var e=this.xa[c]- +this.xa[d],g=(this.xa[c]-a)/e;a=(a-this.xa[d])/e;return g*this.ya[d]+a*this.ya[c]+((g*g*g-g)*this.y2[d]+(a*a*a-a)*this.y2[c])*e*e/6};var r=function(){function b(b,c,d,f){this.gl=a;this.id=a.createTexture();this.width=b;this.height=c;this.format=d;this.type=f;a.bindTexture(a.TEXTURE_2D,this.id);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_MAG_FILTER,a.LINEAR);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_MIN_FILTER,a.LINEAR);a.texParameteri(a.TEXTURE_2D,a.TEXTURE_WRAP_S,a.CLAMP_TO_EDGE);a.texParameteri(a.TEXTURE_2D, +a.TEXTURE_WRAP_T,a.CLAMP_TO_EDGE);b&&c&&a.texImage2D(a.TEXTURE_2D,0,this.format,b,c,0,this.format,this.type,null)}function d(a){null==c&&(c=document.createElement("canvas"));c.width=a.width;c.height=a.height;a=c.getContext("2d");a.clearRect(0,0,c.width,c.height);return a}b.fromElement=function(c){var d=new b(0,0,a.RGBA,a.UNSIGNED_BYTE);d.loadContentsOf(c);return d};b.prototype.loadContentsOf=function(b){this.width=b.width||b.videoWidth;this.height=b.height||b.videoHeight;a.bindTexture(a.TEXTURE_2D, +this.id);a.texImage2D(a.TEXTURE_2D,0,this.format,this.format,this.type,b)};b.prototype.initFromBytes=function(b,c,d){this.width=b;this.height=c;this.format=a.RGBA;this.type=a.UNSIGNED_BYTE;a.bindTexture(a.TEXTURE_2D,this.id);a.texImage2D(a.TEXTURE_2D,0,a.RGBA,b,c,0,a.RGBA,this.type,new Uint8Array(d))};b.prototype.destroy=function(){a.deleteTexture(this.id);this.id=null};b.prototype.use=function(b){a.activeTexture(a.TEXTURE0+(b||0));a.bindTexture(a.TEXTURE_2D,this.id)};b.prototype.unuse=function(b){a.activeTexture(a.TEXTURE0+ +(b||0));a.bindTexture(a.TEXTURE_2D,null)};b.prototype.ensureFormat=function(b,c,d,f){if(1==arguments.length){var h=arguments[0];b=h.width;c=h.height;d=h.format;f=h.type}if(b!=this.width||c!=this.height||d!=this.format||f!=this.type)this.width=b,this.height=c,this.format=d,this.type=f,a.bindTexture(a.TEXTURE_2D,this.id),a.texImage2D(a.TEXTURE_2D,0,this.format,b,c,0,this.format,this.type,null)};b.prototype.drawTo=function(b){a.framebuffer=a.framebuffer||a.createFramebuffer();a.bindFramebuffer(a.FRAMEBUFFER, +a.framebuffer);a.framebufferTexture2D(a.FRAMEBUFFER,a.COLOR_ATTACHMENT0,a.TEXTURE_2D,this.id,0);if(a.checkFramebufferStatus(a.FRAMEBUFFER)!==a.FRAMEBUFFER_COMPLETE)throw Error("incomplete framebuffer");a.viewport(0,0,this.width,this.height);b();a.bindFramebuffer(a.FRAMEBUFFER,null)};var c=null;b.prototype.fillUsingCanvas=function(b){b(d(this));this.format=a.RGBA;this.type=a.UNSIGNED_BYTE;a.bindTexture(a.TEXTURE_2D,this.id);a.texImage2D(a.TEXTURE_2D,0,a.RGBA,a.RGBA,a.UNSIGNED_BYTE,c);return this}; +b.prototype.toImage=function(b){this.use();h.getDefaultShader().drawRect();var f=4*this.width*this.height,k=new Uint8Array(f),n=d(this),p=n.createImageData(this.width,this.height);a.readPixels(0,0,this.width,this.height,a.RGBA,a.UNSIGNED_BYTE,k);for(var m=0;m 0) { + var begin = document.cookie.indexOf(NameOfCookie + "="); + if (begin != -1) { + begin += NameOfCookie.length + 1; + var end = document.cookie.indexOf(";", begin); + if (end == -1) + end = document.cookie.length; + return document.cookie.substring(begin, end); + } + } + return ''; + } + + _setCookie(NameOfCookie, value, expire_days) { + if (expire_days == undefined) + expire_days = 180; + var ExpireDate = new Date(); + ExpireDate.setTime(ExpireDate.getTime() + (expire_days * 24 * 3600 * 1000)); + document.cookie = NameOfCookie + "=" + value + + ((expire_days == null) ? "" : "; expires=" + ExpireDate.toGMTString()); + } + + delCookie(NameOfCookie) { + if (this.getCookie(NameOfCookie)) { + document.cookie = NameOfCookie + "=" + + "; expires=Thu, 01-Jan-70 00:00:01 GMT"; + } + } + + getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + font_pixel_to_height(px) { + return Math.round(px * 0.75); + } + + hex(x) { + x = parseInt(x); + return ("0" + x.toString(16)).slice(-2); + } + + hex_set_hsl(hex, newHsl) { + const rgb = this.hexToRgb(hex); + const hsl = this.rgbToHsl(rgb.r, rgb.g, rgb.b); + if ('h' in newHsl) { + hsl.h = newHsl.h; + } + if ('s' in newHsl) { + hsl.s = newHsl.s; + } + if ('l' in newHsl) { + hsl.l = newHsl.l; + } + return this.hslToHex(hsl.h, hsl.s, hsl.l); + } + + rgbToHex(r, g, b) { + if (r > 255 || g > 255 || b > 255) + throw "Invalid color component"; + var tmp = ((r << 16) | (g << 8) | b).toString(16); + + return "#" + ("000000" + tmp).slice(-6); + } + + hexToRgb(hex) { + if (hex[0] == "#") + hex = hex.substr(1); + if (hex.length == 3) { + var temp = hex; + hex = ''; + temp = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp).slice(1); + for (var i = 0; i < 3; i++) + hex += temp[i] + temp[i]; + } + var triplets = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})/i.exec(hex).slice(1); + return { + r: parseInt(triplets[0], 16), + g: parseInt(triplets[1], 16), + b: parseInt(triplets[2], 16), + a: 255 + }; + } + + hslToHex(h, s, l) { + const rgb = this.hslToRgb(h, s, l); + return this.rgbToHex(rgb.r, rgb.g, rgb.b); + } + + hsvToHex(h, s, v) { + const rgb = this.hsvToRgb(h, s, v); + return this.rgbToHex(rgb.r, rgb.g, rgb.b); + } + + hueToRgb(p, q, t) { + if (t < 0) + t += 1; + if (t > 1) + t -= 1; + if (t < 1 / 6) + return p + (q - p) * 6 * t; + if (t < 1 / 2) + return q; + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6; + return p; + } + + /** + * Converts an HSL color value to RGB. + * Assumes h, s, and l are contained in the set [0, 1] + * Returns r, g, and b in the set [0, 255]. + * + * Credit: https://gist.github.com/mjackson/5311256 + * + * @param {number} h The hue + * @param {number} s The saturation + * @param {number} l The lightness + * @return {Object} The RGB representation, r,g,b as keys. + */ + hslToRgb(h, s, l) { + var r, g, b; + + if (s == 0) { + r = g = b = l; // achromatic + } + else { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = this.hueToRgb(p, q, h + 1 / 3); + g = this.hueToRgb(p, q, h); + b = this.hueToRgb(p, q, h - 1 / 3); + } + + return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }; + } + + /** + * Converts an RGB color value to HSL. Values are in range 0-1. + * But real ranges are 0-360, 0-100%, 0-100% + * + * Credit: https://gist.github.com/mjackson/5311256 + * + * @param {number} r red color value + * @param {number} g green color value + * @param {number} b blue color value + * @return {object} The HSL representation + */ + rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var h, s, l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } + else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + return { h, s, l }; + } + + /** + * Converts an RGB color value to HSV. + * Assumes r, g, and b are contained in the set [0, 255] and + * returns h, s, and v in the set [0, 1]. + * + * Credit: https://gist.github.com/mjackson/5311256 + * + * @param Number r The red color value + * @param Number g The green color value + * @param Number b The blue color value + * @return {object} The HSL representation + */ + rgbToHsv(r, g, b) { + r /= 255, g /= 255, b /= 255; + var max = Math.max(r, g, b), min = Math.min(r, g, b); + var h, s, v = max; + var d = max - min; + s = max == 0 ? 0 : d / max; + if (max == min) { + h = 0; // achromatic + } else { + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + h /= 6; + } + return { h, s, v }; + } + + /** + * Converts an HSV color value to RGB. + * Assumes h, s, and v are contained in the set [0, 1] and + * returns r, g, and b in the set [0, 255]. + * + * Credit: https://gist.github.com/mjackson/5311256 + * + * @param Number h The hue + * @param Number s The saturation + * @param Number v The value + * @return {object} The RGB representation + */ + hsvToRgb(h, s, v) { + var r, g, b; + + var i = Math.floor(h * 6); + var f = h * 6 - i; + var p = v * (1 - s); + var q = v * (1 - f * s); + var t = v * (1 - (1 - f) * s); + + switch (i % 6) { + case 0: r = v, g = t, b = p; break; + case 1: r = q, g = v, b = p; break; + case 2: r = p, g = v, b = t; break; + case 3: r = p, g = q, b = v; break; + case 4: r = t, g = p, b = v; break; + case 5: r = v, g = p, b = q; break; + } + + return { r: r * 255, g: g * 255, b: b * 255 }; + } + + /** + * Converts an HSV color value to HSL. + * Assumes h, s, and v are contained in the set [0, 1] and + * returns h, s, and l in the set [0, 1]. + * + * @param Number h The hue + * @param Number s The saturation + * @param Number v The value + * @return {object} The HSL representation + */ + hsvToHsl(h, s, v) { + return { + h, + s: s * v / Math.max(0.00000001, ((h = (2 - s) * v) < 1 ? h : 2 - h)), + l: h / 2 + }; + } + + /** + * Converts an HSL color value to HSV. + * Assumes h, s, and l are contained in the set [0, 1] and + * returns h, s, and v in the set [0, 1]. + * + * @param Number h The hue + * @param Number s The saturation + * @param Number l The value + * @return {object} The HSV representation + */ + hslToHsv(h, s, l) { + s *= l < .5 ? l : 1 - l; + return { + h, + s: 2 * s / Math.max(0.00000001, (l + s)), + v: l + s + }; + } + + remove_selection() { + if (window.getSelection) { + if (window.getSelection().empty) // Chrome + window.getSelection().empty(); + else if (window.getSelection().removeAllRanges) // Firefox + window.getSelection().removeAllRanges(); + } + else if (document.selection) // IE? + document.selection.empty(); + } + + //credits: richard maloney 2006 + darkenColor(color, v) { + if (color.length > 6) { + color = color.substring(1, color.length); + } + var rgb = parseInt(color, 16); + var r = Math.abs(((rgb >> 16) & 0xFF) + v); + if (r > 255) + r = r - (r - 255); + var g = Math.abs(((rgb >> 8) & 0xFF) + v); + if (g > 255) + g = g - (g - 255); + var b = Math.abs((rgb & 0xFF) + v); + if (b > 255) + b = b - (b - 255); + r = Number(r < 0 || isNaN(r)) ? 0 : ((r > 255) ? 255 : r).toString(16); + if (r.length == 1) + r = '0' + r; + g = Number(g < 0 || isNaN(g)) ? 0 : ((g > 255) ? 255 : g).toString(16); + if (g.length == 1) + g = '0' + g; + b = Number(b < 0 || isNaN(b)) ? 0 : ((b > 255) ? 255 : b).toString(16); + if (b.length == 1) + b = '0' + b; + return "#" + r + g + b; + } + + /** + * JavaScript Number Formatter, author: KPL, KHL + * + * @param {int} n + * @param {int} maximumFractionDigits + * @returns {string} + */ + number_format(n, maximumFractionDigits) { + let x = parseFloat(n); + var number = x.toLocaleString('us', {minimumFractionDigits: 0, maximumFractionDigits: maximumFractionDigits}); + number = number.replaceAll(',', ''); + number = parseFloat(number); + + return number; + } + + check_input_color_support() { + var i = document.createElement("input"); + i.setAttribute("type", "color"); + return i.type !== "text"; + } + + b64toBlob(b64Data, contentType, sliceSize) { + contentType = contentType || ''; + sliceSize = sliceSize || 512; + + var byteCharacters = atob(b64Data); + var byteArrays = []; + + for (var offset = 0; offset < byteCharacters.length; offset += sliceSize) { + var slice = byteCharacters.slice(offset, offset + sliceSize); + + var byteNumbers = new Array(slice.length); + for (var i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + var byteArray = new Uint8Array(byteNumbers); + + byteArrays.push(byteArray); + } + + var blob = new Blob(byteArrays, {type: contentType}); + return blob; + } + + escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + isNumeric(n) { + return !isNaN(parseFloat(n)) && isFinite(n); + } + + ucfirst(string) { + return string.charAt(0).toUpperCase() + string.slice(1); + } + + /** + * change canvas size without loosing data + * + * @param {canvas} canvas + * @param {int} width + * @param {int} height + * @param {int} offset_x + * @param {int} offset_y + */ + change_canvas_size(canvas, width, height, offset_x, offset_y) { + if (offset_x == undefined) + offset_x = 0; + if (offset_y == undefined) + offset_y = 0; + + //copy data; + var tmp = document.createElement('canvas'); + var ctx = tmp.getContext("2d"); + tmp.width = canvas.width; + tmp.height = canvas.height; + ctx.drawImage(canvas, 0, 0); + + canvas.width = Math.max(1, width); + canvas.height = Math.max(1, height); + + //restore image + canvas.getContext("2d").drawImage(tmp, -offset_x, -offset_y); + } + + image_round(ctx_main, mouse_x, mouse_y, size_w, size_h, img_data, anti_aliasing = false) { + //create tmp canvas + var canvasTmp = document.createElement('canvas'); + canvasTmp.width = size_w; + canvasTmp.height = size_h; + + var size_half_w = Math.round(size_w / 2); + var size_half_h = Math.round(size_h / 2); + var ctx = canvasTmp.getContext("2d"); + var width = canvasTmp.width; + var height = canvasTmp.height; + var xx = mouse_x - size_half_w; + var yy = mouse_y - size_half_h; + + ctx.clearRect(0, 0, width, height); + ctx.save(); + //draw main data + ctx.putImageData(img_data, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + + //create form + var gradient = ctx.createRadialGradient(size_half_w, size_half_h, 0, size_half_w, size_half_h, size_half_w); + gradient.addColorStop(0, '#ffffff'); + if (anti_aliasing == true) + gradient.addColorStop(0.8, '#ffffff'); + else + gradient.addColorStop(0.99, '#ffffff'); + gradient.addColorStop(1, 'rgba(255,255,255,0'); + ctx.fillStyle = gradient; + + ctx.beginPath(); + ctx.ellipse(size_half_w, size_half_h, size_w * 2, size_h * 2, 0, 0, 2 * Math.PI); + ctx.fill(); + ctx_main.drawImage(canvasTmp, 0, 0, size_w, size_h, xx, yy, size_w, size_h); + //reset + ctx.restore(); + ctx.clearRect(0, 0, width, height); + } + + is_input(element) { + if (!element) { + return false; + } + if (element.type == 'text' || element.tagName == 'INPUT' || element.type == 'textarea') { + return true; + } else { + return element.closest('.ui_color_picker_gradient, .ui_number_input, .ui_range, .ui_swatches') != null; + } + } + + //if IE 11 or Edge + is_edge_or_ie() { + //ie11 + if( !(window.ActiveXObject) && "ActiveXObject" in window ) + return true; + //edge + if( navigator.userAgent.indexOf('Edge/') != -1 ) + return true; + return false; + } + + // Credit: https://stackoverflow.com/questions/27078285/simple-throttle-in-js + throttle(func, wait, options) { + var context, args, result; + var timeout = null; + var previous = 0; + if (!options) options = {}; + var later = function() { + previous = options.leading === false ? 0 : Date.now(); + timeout = null; + result = func.apply(context, args); + if (!timeout) context = args = null; + }; + return function() { + var now = Date.now(); + if (!previous && options.leading === false) previous = now; + var remaining = wait - (now - previous); + context = this; + args = arguments; + if (remaining <= 0 || remaining > wait) { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + previous = now; + result = func.apply(context, args); + if (!timeout) context = args = null; + } else if (!timeout && options.trailing !== false) { + timeout = setTimeout(later, remaining); + } + return result; + }; + }; + + /** + * draws line that is visible on white and black backgrounds. + * + * @param ctx + * @param start_x + * @param start_y + * @param end_x + * @param end_y + */ + draw_special_line(ctx, start_x, start_y, end_x, end_y){ + const wholeLineWidth = 2 / config.ZOOM; + const halfLineWidth = wholeLineWidth / 2; + + ctx.lineWidth = wholeLineWidth; + ctx.strokeStyle = 'rgb(255, 255, 255)'; + ctx.beginPath(); + ctx.moveTo(start_x - halfLineWidth, start_y); + ctx.lineTo(end_x - halfLineWidth, end_y); + ctx.stroke(); + + ctx.lineWidth = halfLineWidth; + ctx.strokeStyle = 'rgb(0, 0, 0)'; + ctx.beginPath(); + ctx.moveTo(start_x - halfLineWidth, start_y); + ctx.lineTo(end_x - halfLineWidth, end_y); + ctx.stroke(); + } + + /** + * draws control point that is visible on white and black backgrounds. + * + * @param ctx + * @param x + * @param y + * @returns {Path2D} + */ + draw_control_point(ctx, x, y) { + var dx = 0; + var dy = 0; + var block_size = 12 / config.ZOOM; + const wholeLineWidth = 2 / config.ZOOM; + + ctx.strokeStyle = "#000000"; + ctx.fillStyle = "#ffffff"; + ctx.lineWidth = wholeLineWidth; + + //create path + const circle = new Path2D(); + circle.arc(x + dx * block_size, y + dy * block_size, block_size / 2, 0, 2 * Math.PI); + + //draw + ctx.fill(circle); + ctx.stroke(circle); + + return circle; + } + + /** + * converts internal unit (pixel) to user defined + * + * @param data + * @param type + * @param resolution + * @returns {string|number} + */ + get_user_unit(data, type, resolution){ + data = parseFloat(data); + + if(type == 'pixels'){ + //no conversion + return parseInt(data); + } + else if(type == 'inches'){ + return this.number_format(data / resolution, 3); + } + else if(type == 'centimeters'){ + return this.number_format(data / resolution * 2.54, 3); + } + else if(type == 'millimetres'){ + return this.number_format(data / resolution * 25.4, 3); + } + } + + /** + * converts user defined unit to internal (pixels) + * + * @param data + * @param type + * @param resolution + * @returns {number} + */ + get_internal_unit(data, type, resolution){ + data = parseFloat(data); + + if(type == 'pixels'){ + //no conversion + return parseInt(data); + } + else if(type == 'inches'){ + return Math.ceil(data * resolution); + } + else if(type == 'centimeters'){ + return Math.ceil(data * resolution / 2.54); + } + else if(type == 'millimetres'){ + return Math.ceil(data * resolution / 25.4); + } + } + +} +export default Helper_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/imagefilters.js b/paintplus/frontend/src/js/libs/imagefilters.js new file mode 100644 index 0000000..5047b2c --- /dev/null +++ b/paintplus/frontend/src/js/libs/imagefilters.js @@ -0,0 +1,2006 @@ +//about - A Javascript Image filter library for the HTML5 Canvas tag. +//author - https://github.com/arahaya/ImageFilters.js +//demo - http://www.arahaya.com/imagefilters/ + +var ImageFilters = {}; +ImageFilters.utils = { + initSampleCanvas: function () { + var _canvas = document.createElement('canvas'), + _context = _canvas.getContext('2d'); + + _canvas.width = 0; + _canvas.height = 0; + + this.getSampleCanvas = function () { + return _canvas; + }; + this.getSampleContext = function () { + return _context; + }; + this.createImageData = (_context.createImageData) ? function (w, h) { + return _context.createImageData(w, h); + } : function (w, h) { + return new ImageData(w, h); + }; + }, + getSampleCanvas: function () { + this.initSampleCanvas(); + return this.getSampleCanvas(); + }, + getSampleContext: function () { + this.initSampleCanvas(); + return this.getSampleContext(); + }, + createImageData: function (w, h) { + this.initSampleCanvas(); + return this.createImageData(w, h); + }, + clamp: function (value) { + return value > 255 ? 255 : value < 0 ? 0 : value; + }, + buildMap: function (f) { + for (var m = [], k = 0, v; k < 256; k += 1) { + m[k] = (v = f(k)) > 255 ? 255 : v < 0 ? 0 : v | 0; + } + return m; + }, + applyMap: function (src, dst, map) { + for (var i = 0, l = src.length; i < l; i += 4) { + dst[i] = map[src[i]]; + dst[i + 1] = map[src[i + 1]]; + dst[i + 2] = map[src[i + 2]]; + dst[i + 3] = src[i + 3]; + } + }, + mapRGB: function (src, dst, func) { + this.applyMap(src, dst, this.buildMap(func)); + }, + getPixelIndex: function (x, y, width, height, edge) { + if (x < 0 || x >= width || y < 0 || y >= height) { + switch (edge) { + case 1: // clamp + x = x < 0 ? 0 : x >= width ? width - 1 : x; + y = y < 0 ? 0 : y >= height ? height - 1 : y; + break; + case 2: // wrap + x = (x %= width) < 0 ? x + width : x; + y = (y %= height) < 0 ? y + height : y; + break; + default: // transparent + return null; + } + } + return (y * width + x) << 2; + }, + getPixel: function (src, x, y, width, height, edge) { + if (x < 0 || x >= width || y < 0 || y >= height) { + switch (edge) { + case 1: // clamp + x = x < 0 ? 0 : x >= width ? width - 1 : x; + y = y < 0 ? 0 : y >= height ? height - 1 : y; + break; + case 2: // wrap + x = (x %= width) < 0 ? x + width : x; + y = (y %= height) < 0 ? y + height : y; + break; + default: // transparent + return 0; + } + } + + var i = (y * width + x) << 2; + + // ARGB + return src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + }, + getPixelByIndex: function (src, i) { + return src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + }, + /** + * one of the most important functions in this library. + * I want to make this as fast as possible. + */ + copyBilinear: function (src, x, y, width, height, dst, dstIndex, edge) { + var fx = x < 0 ? x - 1 | 0 : x | 0, // Math.floor(x) + fy = y < 0 ? y - 1 | 0 : y | 0, // Math.floor(y) + wx = x - fx, + wy = y - fy, + i, + nw = 0, ne = 0, sw = 0, se = 0, + cx, cy, + r, g, b, a; + + if (fx >= 0 && fx < (width - 1) && fy >= 0 && fy < (height - 1)) { + // in bounds, no edge actions required + i = (fy * width + fx) << 2; + + if (wx || wy) { + nw = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + + i += 4; + ne = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + + i = (i - 8) + (width << 2); + sw = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + + i += 4; + se = src[i + 3] << 24 | src[i] << 16 | src[i + 1] << 8 | src[i + 2]; + } else { + // no interpolation required + dst[dstIndex] = src[i]; + dst[dstIndex + 1] = src[i + 1]; + dst[dstIndex + 2] = src[i + 2]; + dst[dstIndex + 3] = src[i + 3]; + return; + } + } else { + // edge actions required + nw = this.getPixel(src, fx, fy, width, height, edge); + + if (wx || wy) { + ne = this.getPixel(src, fx + 1, fy, width, height, edge); + sw = this.getPixel(src, fx, fy + 1, width, height, edge); + se = this.getPixel(src, fx + 1, fy + 1, width, height, edge); + } else { + // no interpolation required + dst[dstIndex] = nw >> 16 & 0xFF; + dst[dstIndex + 1] = nw >> 8 & 0xFF; + dst[dstIndex + 2] = nw & 0xFF; + dst[dstIndex + 3] = nw >> 24 & 0xFF; + return; + } + } + + cx = 1 - wx; + cy = 1 - wy; + r = ((nw >> 16 & 0xFF) * cx + (ne >> 16 & 0xFF) * wx) * cy + ((sw >> 16 & 0xFF) * cx + (se >> 16 & 0xFF) * wx) * wy; + g = ((nw >> 8 & 0xFF) * cx + (ne >> 8 & 0xFF) * wx) * cy + ((sw >> 8 & 0xFF) * cx + (se >> 8 & 0xFF) * wx) * wy; + b = ((nw & 0xFF) * cx + (ne & 0xFF) * wx) * cy + ((sw & 0xFF) * cx + (se & 0xFF) * wx) * wy; + a = ((nw >> 24 & 0xFF) * cx + (ne >> 24 & 0xFF) * wx) * cy + ((sw >> 24 & 0xFF) * cx + (se >> 24 & 0xFF) * wx) * wy; + + dst[dstIndex] = r > 255 ? 255 : r < 0 ? 0 : r | 0; + dst[dstIndex + 1] = g > 255 ? 255 : g < 0 ? 0 : g | 0; + dst[dstIndex + 2] = b > 255 ? 255 : b < 0 ? 0 : b | 0; + dst[dstIndex + 3] = a > 255 ? 255 : a < 0 ? 0 : a | 0; + }, + /** + * @param r 0 <= n <= 255 + * @param g 0 <= n <= 255 + * @param b 0 <= n <= 255 + * @return Array(h, s, l) + */ + rgbToHsl: function (r, g, b) { + r /= 255; + g /= 255; + b /= 255; + +// var max = Math.max(r, g, b), +// min = Math.min(r, g, b), + var max = (r > g) ? (r > b) ? r : b : (g > b) ? g : b, + min = (r < g) ? (r < b) ? r : b : (g < b) ? g : b, + chroma = max - min, + h = 0, + s = 0, + // Lightness + l = (min + max) / 2; + + if (chroma !== 0) { + // Hue + if (r === max) { + h = (g - b) / chroma + ((g < b) ? 6 : 0); + } else if (g === max) { + h = (b - r) / chroma + 2; + } else { + h = (r - g) / chroma + 4; + } + h /= 6; + + // Saturation + s = (l > 0.5) ? chroma / (2 - max - min) : chroma / (max + min); + } + + return [h, s, l]; + }, + /** + * @param h 0.0 <= n <= 1.0 + * @param s 0.0 <= n <= 1.0 + * @param l 0.0 <= n <= 1.0 + * @return Array(r, g, b) + */ + hslToRgb: function (h, s, l) { + var m1, m2, hue, + r, g, b, + rgb = []; + + if (s === 0) { + r = g = b = l * 255 + 0.5 | 0; + rgb = [r, g, b]; + } else { + if (l <= 0.5) { + m2 = l * (s + 1); + } else { + m2 = l + s - l * s; + } + + m1 = l * 2 - m2; + hue = h + 1 / 3; + + var tmp; + for (var i = 0; i < 3; i += 1) { + if (hue < 0) { + hue += 1; + } else if (hue > 1) { + hue -= 1; + } + + if (6 * hue < 1) { + tmp = m1 + (m2 - m1) * hue * 6; + } else if (2 * hue < 1) { + tmp = m2; + } else if (3 * hue < 2) { + tmp = m1 + (m2 - m1) * (2 / 3 - hue) * 6; + } else { + tmp = m1; + } + + rgb[i] = tmp * 255 + 0.5 | 0; + + hue -= 1 / 3; + } + } + + return rgb; + } +}; + + +ImageFilters.Translate = function (srcImageData, x, y, interpolation) { + +}; +ImageFilters.Scale = function (srcImageData, scaleX, scaleY, interpolation) { + +}; +ImageFilters.Rotate = function (srcImageData, originX, originY, angle, resize, interpolation) { + +}; +ImageFilters.Affine = function (srcImageData, matrix, resize, interpolation) { + +}; +ImageFilters.UnsharpMask = function (srcImageData, level) { + +}; + +ImageFilters.ConvolutionFilter = function (srcImageData, matrixX, matrixY, matrix, divisor, bias, preserveAlpha, clamp, color, alpha) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + divisor = divisor || 1; + bias = bias || 0; + + // default true + (preserveAlpha !== false) && (preserveAlpha = true); + (clamp !== false) && (clamp = true); + + color = color || 0; + alpha = alpha || 0; + + var index = 0, + rows = matrixX >> 1, + cols = matrixY >> 1, + clampR = color >> 16 & 0xFF, + clampG = color >> 8 & 0xFF, + clampB = color & 0xFF, + clampA = alpha * 0xFF; + + for (var y = 0; y < srcHeight; y += 1) { + for (var x = 0; x < srcWidth; x += 1, index += 4) { + var r = 0, + g = 0, + b = 0, + a = 0, + replace = false, + mIndex = 0, + v; + + for (var row = -rows; row <= rows; row += 1) { + var rowIndex = y + row, + offset; + + if (0 <= rowIndex && rowIndex < srcHeight) { + offset = rowIndex * srcWidth; + } else if (clamp) { + offset = y * srcWidth; + } else { + replace = true; + } + + for (var col = -cols; col <= cols; col += 1) { + var m = matrix[mIndex++]; + + if (m !== 0) { + var colIndex = x + col; + + if (!(0 <= colIndex && colIndex < srcWidth)) { + if (clamp) { + colIndex = x; + } else { + replace = true; + } + } + + if (replace) { + r += m * clampR; + g += m * clampG; + b += m * clampB; + a += m * clampA; + } else { + var p = (offset + colIndex) << 2; + r += m * srcPixels[p]; + g += m * srcPixels[p + 1]; + b += m * srcPixels[p + 2]; + a += m * srcPixels[p + 3]; + } + } + } + } + + dstPixels[index] = (v = r / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0; + dstPixels[index + 1] = (v = g / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0; + dstPixels[index + 2] = (v = b / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0; + dstPixels[index + 3] = preserveAlpha ? srcPixels[index + 3] : (v = a / divisor + bias) > 255 ? 255 : v < 0 ? 0 : v | 0; + } + } + + return dstImageData; +}; + +/** + * @param threshold 0.0 <= n <= 1.0 + */ +ImageFilters.Binarize = function (srcImageData, threshold) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + if (isNaN(threshold)) { + threshold = 0.5; + } + + threshold *= 255; + + for (var i = 0; i < srcLength; i += 4) { + var avg = srcPixels[i] + srcPixels[i + 1] + srcPixels[i + 2] / 3; + + dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = avg <= threshold ? 0 : 255; + dstPixels[i + 3] = 255; + } + + return dstImageData; +}; + +ImageFilters.BlendAdd = function (srcImageData, blendImageData, dx, dy) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data, + blendPixels = blendImageData.data; + + var v; + + for (var i = 0; i < srcLength; i += 4) { + dstPixels[i] = ((v = srcPixels[i] + blendPixels[i]) > 255) ? 255 : v; + dstPixels[i + 1] = ((v = srcPixels[i + 1] + blendPixels[i + 1]) > 255) ? 255 : v; + dstPixels[i + 2] = ((v = srcPixels[i + 2] + blendPixels[i + 2]) > 255) ? 255 : v; + dstPixels[i + 3] = 255; + } + + return dstImageData; +}; + +ImageFilters.BlendSubtract = function (srcImageData, blendImageData, dx, dy) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data, + blendPixels = blendImageData.data; + + var v; + + for (var i = 0; i < srcLength; i += 4) { + dstPixels[i] = ((v = srcPixels[i] - blendPixels[i]) < 0) ? 0 : v; + dstPixels[i + 1] = ((v = srcPixels[i + 1] - blendPixels[i + 1]) < 0) ? 0 : v; + dstPixels[i + 2] = ((v = srcPixels[i + 2] - blendPixels[i + 2]) < 0) ? 0 : v; + dstPixels[i + 3] = 255; + } + + return dstImageData; +}; + +/** + * Algorithm based on BoxBlurFilter.java by Huxtable.com + * @see http://www.jhlabs.com/ip/blurring.html + * Copyright 2005 Huxtable.com. All rights reserved. + */ +ImageFilters.BoxBlur = (function () { + var blur = function (src, dst, width, height, radius) { + var tableSize = radius * 2 + 1; + var radiusPlus1 = radius + 1; + var widthMinus1 = width - 1; + + var r, g, b, a; + + var srcIndex = 0; + var dstIndex; + var p, next, prev; + var i, l, x, y, + nextIndex, prevIndex; + + var sumTable = []; + for (i = 0, l = 256 * tableSize; i < l; i += 1) { + sumTable[i] = i / tableSize | 0; + } + + for (y = 0; y < height; y += 1) { + r = g = b = a = 0; + dstIndex = y; + + p = srcIndex << 2; + r += radiusPlus1 * src[p]; + g += radiusPlus1 * src[p + 1]; + b += radiusPlus1 * src[p + 2]; + a += radiusPlus1 * src[p + 3]; + + for (i = 1; i <= radius; i += 1) { + p = (srcIndex + (i < width ? i : widthMinus1)) << 2; + r += src[p]; + g += src[p + 1]; + b += src[p + 2]; + a += src[p + 3]; + } + + for (x = 0; x < width; x += 1) { + p = dstIndex << 2; + dst[p] = sumTable[r]; + dst[p + 1] = sumTable[g]; + dst[p + 2] = sumTable[b]; + dst[p + 3] = sumTable[a]; + + nextIndex = x + radiusPlus1; + if (nextIndex > widthMinus1) { + nextIndex = widthMinus1; + } + + prevIndex = x - radius; + if (prevIndex < 0) { + prevIndex = 0; + } + + next = (srcIndex + nextIndex) << 2; + prev = (srcIndex + prevIndex) << 2; + + r += src[next] - src[prev]; + g += src[next + 1] - src[prev + 1]; + b += src[next + 2] - src[prev + 2]; + a += src[next + 3] - src[prev + 3]; + + dstIndex += height; + } + srcIndex += width; + } + }; + + return function (srcImageData, hRadius, vRadius, quality) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data, + tmpImageData = this.utils.createImageData(srcWidth, srcHeight), + tmpPixels = tmpImageData.data; + + for (var i = 0; i < quality; i += 1) { + // only use the srcPixels on the first loop + blur(i ? dstPixels : srcPixels, tmpPixels, srcWidth, srcHeight, hRadius); + blur(tmpPixels, dstPixels, srcHeight, srcWidth, vRadius); + } + + return dstImageData; + }; +}()); + +/** + * @ param strength 1 <= n <= 4 + */ +ImageFilters.GaussianBlur = function (srcImageData, strength) { + var size, matrix, divisor; + + switch (strength) { + case 2: + size = 5; + matrix = [ + 1, 1, 2, 1, 1, + 1, 2, 4, 2, 1, + 2, 4, 8, 4, 2, + 1, 2, 4, 2, 1, + 1, 1, 2, 1, 1 + ]; + divisor = 52; + break; + case 3: + size = 7; + matrix = [ + 1, 1, 2, 2, 2, 1, 1, + 1, 2, 2, 4, 2, 2, 1, + 2, 2, 4, 8, 4, 2, 2, + 2, 4, 8, 16, 8, 4, 2, + 2, 2, 4, 8, 4, 2, 2, + 1, 2, 2, 4, 2, 2, 1, + 1, 1, 2, 2, 2, 1, 1 + ]; + divisor = 140; + break; + case 4: + size = 15; + matrix = [ + 2, 2, 3, 4, 5, 5, 6, 6, 6, 5, 5, 4, 3, 2, 2, + 2, 3, 4, 5, 7, 7, 8, 8, 8, 7, 7, 5, 4, 3, 2, + 3, 4, 6, 7, 9, 10, 10, 11, 10, 10, 9, 7, 6, 4, 3, + 4, 5, 7, 9, 10, 12, 13, 13, 13, 12, 10, 9, 7, 5, 4, + 5, 7, 9, 11, 13, 14, 15, 16, 15, 14, 13, 11, 9, 7, 5, + 5, 7, 10, 12, 14, 16, 17, 18, 17, 16, 14, 12, 10, 7, 5, + 6, 8, 10, 13, 15, 17, 19, 19, 19, 17, 15, 13, 10, 8, 6, + 6, 8, 11, 13, 16, 18, 19, 20, 19, 18, 16, 13, 11, 8, 6, + 6, 8, 10, 13, 15, 17, 19, 19, 19, 17, 15, 13, 10, 8, 6, + 5, 7, 10, 12, 14, 16, 17, 18, 17, 16, 14, 12, 10, 7, 5, + 5, 7, 9, 11, 13, 14, 15, 16, 15, 14, 13, 11, 9, 7, 5, + 4, 5, 7, 9, 10, 12, 13, 13, 13, 12, 10, 9, 7, 5, 4, + 3, 4, 6, 7, 9, 10, 10, 11, 10, 10, 9, 7, 6, 4, 3, + 2, 3, 4, 5, 7, 7, 8, 8, 8, 7, 7, 5, 4, 3, 2, + 2, 2, 3, 4, 5, 5, 6, 6, 6, 5, 5, 4, 3, 2, 2 + ]; + divisor = 2044; + break; + default: + size = 3; + matrix = [ + 1, 2, 1, + 2, 4, 2, + 1, 2, 1 + ]; + divisor = 16; + break; + } + return this.ConvolutionFilter(srcImageData, size, size, matrix, divisor, 0, false); +}; + +/** + * Stack Blur Algorithm by Mario Klingemann + * @see http://incubator.quasimondo.com/processing/fast_blur_deluxe.php + */ +/* + Copyright (c) 2010 Mario Klingemann + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + */ +ImageFilters.StackBlur = (function () { + var mul_table = [ + 512, 512, 456, 512, 328, 456, 335, 512, 405, 328, 271, 456, 388, 335, 292, 512, + 454, 405, 364, 328, 298, 271, 496, 456, 420, 388, 360, 335, 312, 292, 273, 512, + 482, 454, 428, 405, 383, 364, 345, 328, 312, 298, 284, 271, 259, 496, 475, 456, + 437, 420, 404, 388, 374, 360, 347, 335, 323, 312, 302, 292, 282, 273, 265, 512, + 497, 482, 468, 454, 441, 428, 417, 405, 394, 383, 373, 364, 354, 345, 337, 328, + 320, 312, 305, 298, 291, 284, 278, 271, 265, 259, 507, 496, 485, 475, 465, 456, + 446, 437, 428, 420, 412, 404, 396, 388, 381, 374, 367, 360, 354, 347, 341, 335, + 329, 323, 318, 312, 307, 302, 297, 292, 287, 282, 278, 273, 269, 265, 261, 512, + 505, 497, 489, 482, 475, 468, 461, 454, 447, 441, 435, 428, 422, 417, 411, 405, + 399, 394, 389, 383, 378, 373, 368, 364, 359, 354, 350, 345, 341, 337, 332, 328, + 324, 320, 316, 312, 309, 305, 301, 298, 294, 291, 287, 284, 281, 278, 274, 271, + 268, 265, 262, 259, 257, 507, 501, 496, 491, 485, 480, 475, 470, 465, 460, 456, + 451, 446, 442, 437, 433, 428, 424, 420, 416, 412, 408, 404, 400, 396, 392, 388, + 385, 381, 377, 374, 370, 367, 363, 360, 357, 354, 350, 347, 344, 341, 338, 335, + 332, 329, 326, 323, 320, 318, 315, 312, 310, 307, 304, 302, 299, 297, 294, 292, + 289, 287, 285, 282, 280, 278, 275, 273, 271, 269, 267, 265, 263, 261, 259]; + + + var shg_table = [ + 9, 11, 12, 13, 13, 14, 14, 15, 15, 15, 15, 16, 16, 16, 16, 17, + 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19, + 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, + 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21, + 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, + 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, + 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, + 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23, + 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, + 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, + 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, + 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24]; + + function BlurStack() { + this.r = 0; + this.g = 0; + this.b = 0; + this.a = 0; + this.next = null; + } + + return function (srcImageData, radius) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.Clone(srcImageData), + dstPixels = dstImageData.data; + + var x, y, i, p, yp, yi, yw, + r_sum, g_sum, b_sum, a_sum, + r_out_sum, g_out_sum, b_out_sum, a_out_sum, + r_in_sum, g_in_sum, b_in_sum, a_in_sum, + pr, pg, pb, pa, rbs, + div = radius + radius + 1, + w4 = srcWidth << 2, + widthMinus1 = srcWidth - 1, + heightMinus1 = srcHeight - 1, + radiusPlus1 = radius + 1, + sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2, + stackStart = new BlurStack(), + stack = stackStart, + stackIn, stackOut, stackEnd, + mul_sum = mul_table[radius], + shg_sum = shg_table[radius]; + + for (i = 1; i < div; i += 1) { + stack = stack.next = new BlurStack(); + if (i == radiusPlus1) { + stackEnd = stack; + } + } + + stack.next = stackStart; + yw = yi = 0; + + for (y = 0; y < srcHeight; y += 1) { + r_in_sum = g_in_sum = b_in_sum = a_in_sum = r_sum = g_sum = b_sum = a_sum = 0; + + r_out_sum = radiusPlus1 * (pr = dstPixels[yi]); + g_out_sum = radiusPlus1 * (pg = dstPixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = dstPixels[yi + 2]); + a_out_sum = radiusPlus1 * (pa = dstPixels[yi + 3]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + a_sum += sumFactor * pa; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i += 1) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack.a = pa; + stack = stack.next; + } + + for (i = 1; i < radiusPlus1; i += 1) { + p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2); + r_sum += (stack.r = (pr = dstPixels[p])) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = (pg = dstPixels[p + 1])) * rbs; + b_sum += (stack.b = (pb = dstPixels[p + 2])) * rbs; + a_sum += (stack.a = (pa = dstPixels[p + 3])) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + a_in_sum += pa; + + stack = stack.next; + } + + stackIn = stackStart; + stackOut = stackEnd; + + for (x = 0; x < srcWidth; x += 1) { + dstPixels[yi] = (r_sum * mul_sum) >> shg_sum; + dstPixels[yi + 1] = (g_sum * mul_sum) >> shg_sum; + dstPixels[yi + 2] = (b_sum * mul_sum) >> shg_sum; + dstPixels[yi + 3] = (a_sum * mul_sum) >> shg_sum; + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + a_sum -= a_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + a_out_sum -= stackIn.a; + + p = (yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1)) << 2; + + r_in_sum += (stackIn.r = dstPixels[p]); + g_in_sum += (stackIn.g = dstPixels[p + 1]); + b_in_sum += (stackIn.b = dstPixels[p + 2]); + a_in_sum += (stackIn.a = dstPixels[p + 3]); + + r_sum += r_in_sum; + g_sum += g_in_sum; + b_sum += b_in_sum; + a_sum += a_in_sum; + + stackIn = stackIn.next; + + r_out_sum += (pr = stackOut.r); + g_out_sum += (pg = stackOut.g); + b_out_sum += (pb = stackOut.b); + a_out_sum += (pa = stackOut.a); + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + a_in_sum -= pa; + + stackOut = stackOut.next; + + yi += 4; + } + + yw += srcWidth; + } + + for (x = 0; x < srcWidth; x += 1) { + g_in_sum = b_in_sum = a_in_sum = r_in_sum = g_sum = b_sum = a_sum = r_sum = 0; + + yi = x << 2; + r_out_sum = radiusPlus1 * (pr = dstPixels[yi]); + g_out_sum = radiusPlus1 * (pg = dstPixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = dstPixels[yi + 2]); + a_out_sum = radiusPlus1 * (pa = dstPixels[yi + 3]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + a_sum += sumFactor * pa; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i += 1) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack.a = pa; + stack = stack.next; + } + + yp = srcWidth; + + for (i = 1; i <= radius; i += 1) { + yi = (yp + x) << 2; + + r_sum += (stack.r = (pr = dstPixels[yi])) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = (pg = dstPixels[yi + 1])) * rbs; + b_sum += (stack.b = (pb = dstPixels[yi + 2])) * rbs; + a_sum += (stack.a = (pa = dstPixels[yi + 3])) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + a_in_sum += pa; + + stack = stack.next; + + if (i < heightMinus1) { + yp += srcWidth; + } + } + + yi = x; + stackIn = stackStart; + stackOut = stackEnd; + + for (y = 0; y < srcHeight; y += 1) { + p = yi << 2; + dstPixels[p] = (r_sum * mul_sum) >> shg_sum; + dstPixels[p + 1] = (g_sum * mul_sum) >> shg_sum; + dstPixels[p + 2] = (b_sum * mul_sum) >> shg_sum; + dstPixels[p + 3] = (a_sum * mul_sum) >> shg_sum; + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + a_sum -= a_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + a_out_sum -= stackIn.a; + + p = (x + (((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * srcWidth)) << 2; + + r_sum += (r_in_sum += (stackIn.r = dstPixels[p])); + g_sum += (g_in_sum += (stackIn.g = dstPixels[p + 1])); + b_sum += (b_in_sum += (stackIn.b = dstPixels[p + 2])); + a_sum += (a_in_sum += (stackIn.a = dstPixels[p + 3])); + + stackIn = stackIn.next; + + r_out_sum += (pr = stackOut.r); + g_out_sum += (pg = stackOut.g); + b_out_sum += (pb = stackOut.b); + a_out_sum += (pa = stackOut.a); + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + a_in_sum -= pa; + + stackOut = stackOut.next; + + yi += srcWidth; + } + } + + return dstImageData; + } +}()); + +/** + * TV based algorithm + */ +ImageFilters.Brightness = function (srcImageData, brightness) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + value += brightness; + return (value > 255) ? 255 : value; + }); + + return dstImageData; +}; + +/** + * GIMP algorithm modified. pretty close to fireworks + * @param brightness -100 <= n <= 100 + * @param contrast -100 <= n <= 100 + */ +ImageFilters.BrightnessContrastGimp = function (srcImageData, brightness, contrast) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data, + p4 = Math.PI / 4; + + // fix to -1 <= n <= 1 + brightness /= 100; + + // fix to -99 <= n <= 99 + contrast *= 0.99; + // fix to -1 < n < 1 + contrast /= 100; + // apply GIMP formula + contrast = Math.tan((contrast + 1) * p4); + + // get the average color + for (var avg = 0, i = 0; i < srcLength; i += 4) { + avg += (srcPixels[i] * 19595 + srcPixels[i + 1] * 38470 + srcPixels[i + 2] * 7471) >> 16; + } + avg = avg / (srcLength / 4); + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + if (brightness < 0) { + value = value * (1 + brightness); + } else if (brightness > 0) { + value = value + ((255 - value) * brightness); + } + //value += brightness; + + if (contrast !== 0) { + value = (value - avg) * contrast + avg; + } + return value + 0.5 | 0; + }); + return dstImageData; +}; + +/** + * more like the new photoshop algorithm + * @param brightness -100 <= n <= 100 + * @param contrast -100 <= n <= 100 + */ +ImageFilters.BrightnessContrastPhotoshop = function (srcImageData, brightness, contrast) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + // fix to 0 <= n <= 2; + brightness = (brightness + 100) / 100; + contrast = (contrast + 100) / 100; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + value *= brightness; + value = (value - 127.5) * contrast + 127.5; + return value + 0.5 | 0; + }); + return dstImageData; +}; + +ImageFilters.Channels = function (srcImageData, channel) { + var matrix; + + switch (channel) { + case 2: // green + matrix = [ + 0, 1, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 1, 0, 0, 0, + 0, 0, 0, 1, 0 + ]; + break; + case 3: // blue + matrix = [ + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 1, 0, 0, + 0, 0, 0, 1, 0 + ]; + break; + default: // red + matrix = [ + 1, 0, 0, 0, 0, + 1, 0, 0, 0, 0, + 1, 0, 0, 0, 0, + 0, 0, 0, 1, 0 + ]; + break; + + } + + return this.ColorMatrixFilter(srcImageData, matrix); +}; + +ImageFilters.Clone = function (srcImageData) { + return this.Copy(srcImageData, this.utils.createImageData(srcImageData.width, srcImageData.height)); +}; + +/** + * slower + */ +ImageFilters.CloneBuiltin = function (srcImageData) { + var srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + canvas = this.utils.getSampleCanvas(), + context = this.utils.getSampleContext(), + dstImageData; + + canvas.width = srcWidth; + canvas.height = srcHeight; + + context.putImageData(srcImageData, 0, 0); + dstImageData = context.getImageData(0, 0, srcWidth, srcHeight); + + canvas.width = 0; + canvas.height = 0; + + return dstImageData; +}; + +ImageFilters.ColorMatrixFilter = function (srcImageData, matrix) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var m0 = matrix[0], + m1 = matrix[1], + m2 = matrix[2], + m3 = matrix[3], + m4 = matrix[4], + m5 = matrix[5], + m6 = matrix[6], + m7 = matrix[7], + m8 = matrix[8], + m9 = matrix[9], + m10 = matrix[10], + m11 = matrix[11], + m12 = matrix[12], + m13 = matrix[13], + m14 = matrix[14], + m15 = matrix[15], + m16 = matrix[16], + m17 = matrix[17], + m18 = matrix[18], + m19 = matrix[19]; + + var value, i, r, g, b, a; + for (i = 0; i < srcLength; i += 4) { + r = srcPixels[i]; + g = srcPixels[i + 1]; + b = srcPixels[i + 2]; + a = srcPixels[i + 3]; + + dstPixels[i] = (value = r * m0 + g * m1 + b * m2 + a * m3 + m4) > 255 ? 255 : value < 0 ? 0 : value | 0; + dstPixels[i + 1] = (value = r * m5 + g * m6 + b * m7 + a * m8 + m9) > 255 ? 255 : value < 0 ? 0 : value | 0; + dstPixels[i + 2] = (value = r * m10 + g * m11 + b * m12 + a * m13 + m14) > 255 ? 255 : value < 0 ? 0 : value | 0; + dstPixels[i + 3] = (value = r * m15 + g * m16 + b * m17 + a * m18 + m19) > 255 ? 255 : value < 0 ? 0 : value | 0; + } + + return dstImageData; +}; + +ImageFilters.ColorTransformFilter = function ( + srcImageData, redMultiplier, greenMultiplier, blueMultiplier, alphaMultiplier, + redOffset, greenOffset, blueOffset, alphaOffset) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var i, v; + for (i = 0; i < srcLength; i += 4) { + dstPixels[i] = (v = srcPixels[i] * redMultiplier + redOffset) > 255 ? 255 : v < 0 ? 0 : v; + dstPixels[i + 1] = (v = srcPixels[i + 1] * greenMultiplier + greenOffset) > 255 ? 255 : v < 0 ? 0 : v; + dstPixels[i + 2] = (v = srcPixels[i + 2] * blueMultiplier + blueOffset) > 255 ? 255 : v < 0 ? 0 : v; + dstPixels[i + 3] = (v = srcPixels[i + 3] * alphaMultiplier + alphaOffset) > 255 ? 255 : v < 0 ? 0 : v; + } + + return dstImageData; +}; + +ImageFilters.Copy = function (srcImageData, dstImageData) { + var srcPixels = srcImageData.data, + srcLength = srcPixels.length, + dstPixels = dstImageData.data; + + while (srcLength--) { + dstPixels[srcLength] = srcPixels[srcLength]; + } + + return dstImageData; +}; + +ImageFilters.Crop = function (srcImageData, x, y, width, height) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(width, height), + dstPixels = dstImageData.data; + + var srcLeft = Math.max(x, 0), + srcTop = Math.max(y, 0), + srcRight = Math.min(x + width, srcWidth), + srcBottom = Math.min(y + height, srcHeight), + dstLeft = srcLeft - x, + dstTop = srcTop - y, + srcRow, srcCol, srcIndex, dstIndex; + + for (srcRow = srcTop, dstRow = dstTop; srcRow < srcBottom; srcRow += 1, dstRow += 1) { + for (srcCol = srcLeft, dstCol = dstLeft; srcCol < srcRight; srcCol += 1, dstCol += 1) { + srcIndex = (srcRow * srcWidth + srcCol) << 2; + dstIndex = (dstRow * width + dstCol) << 2; + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + } + } + + return dstImageData; +}; + +ImageFilters.CropBuiltin = function (srcImageData, x, y, width, height) { + var srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + canvas = this.utils.getSampleCanvas(), + context = this.utils.getSampleContext(); + + canvas.width = srcWidth; + canvas.height = srcHeight; + context.putImageData(srcImageData, 0, 0); + var result = context.getImageData(x, y, width, height); + + canvas.width = 0; + canvas.height = 0; + + return result; +}; + +/** + * sets to the average of the highest and lowest contrast + */ +ImageFilters.Desaturate = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + for (var i = 0; i < srcLength; i += 4) { + var r = srcPixels[i], + g = srcPixels[i + 1], + b = srcPixels[i + 2], + max = (r > g) ? (r > b) ? r : b : (g > b) ? g : b, + min = (r < g) ? (r < b) ? r : b : (g < b) ? g : b, + avg = ((max + min) / 2) + 0.5 | 0; + + dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = avg; + dstPixels[i + 3] = srcPixels[i + 3]; + } + + return dstImageData; +}; + +ImageFilters.DisplacementMapFilter = function (srcImageData, mapImageData, mapX, mapY, componentX, componentY, scaleX, scaleY, mode) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = ImageFilters.Clone(srcImageData), + dstPixels = dstImageData.data; + + mapX || (mapX = 0); + mapY || (mapY = 0); + componentX || (componentX = 0); // red? + componentY || (componentY = 0); + scaleX || (scaleX = 0); + scaleY || (scaleY = 0); + mode || (mode = 2); // wrap + + var mapWidth = mapImageData.width, + mapHeight = mapImageData.height, + mapPixels = mapImageData.data, + mapRight = mapWidth + mapX, + mapBottom = mapHeight + mapY, + dstIndex, srcIndex, mapIndex, + cx, cy, tx, ty, x, y; + + for (x = 0; x < srcWidth; x += 1) { + for (y = 0; y < srcHeight; y += 1) { + + dstIndex = (y * srcWidth + x) << 2; + + if (x < mapX || y < mapY || x >= mapRight || y >= mapBottom) { + // out of the map bounds + // copy src to dst + srcIndex = dstIndex; + } else { + // apply map + mapIndex = ((y - mapY) * mapWidth + (x - mapX)) << 2; + + // tx = x + ((componentX(x, y) - 128) * scaleX) / 256 + cx = mapPixels[mapIndex + componentX]; + tx = x + (((cx - 128) * scaleX) >> 8); + + // tx = y + ((componentY(x, y) - 128) * scaleY) / 256 + cy = mapPixels[mapIndex + componentY]; + ty = y + (((cy - 128) * scaleY) >> 8); + + srcIndex = ImageFilters.utils.getPixelIndex(tx + 0.5 | 0, ty + 0.5 | 0, srcWidth, srcHeight, mode); + if (srcIndex === null) { + // if mode == ignore and (tx,ty) is out of src bounds + // then copy (x,y) to dst + srcIndex = dstIndex; + } + } + + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + } + } + + return dstImageData; +}; + +/** + * Floyd-Steinberg algorithm + * @param levels 2 <= n <= 255 + */ +ImageFilters.Dither = function (srcImageData, levels) { + var srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + dstImageData = this.Clone(srcImageData), + dstPixels = dstImageData.data; + + levels = levels < 2 ? 2 : levels > 255 ? 255 : levels; + + // Build a color map using the same algorithm as the posterize filter. + var posterize, + levelMap = [], + levelsMinus1 = levels - 1, + j = 0, + k = 0, + i; + + for (i = 0; i < levels; i += 1) { + levelMap[i] = (255 * i) / levelsMinus1; + } + + posterize = this.utils.buildMap(function (value) { + var ret = levelMap[j]; + + k += levels; + + if (k > 255) { + k -= 255; + j += 1; + } + + return ret; + }); + + // Apply the dithering algorithm to each pixel + var x, y, + index, + old_r, old_g, old_b, + new_r, new_g, new_b, + err_r, err_g, err_b, + nbr_r, nbr_g, nbr_b, + srcWidthMinus1 = srcWidth - 1, + srcHeightMinus1 = srcHeight - 1, + A = 7 / 16, + B = 3 / 16, + C = 5 / 16, + D = 1 / 16; + + for (y = 0; y < srcHeight; y += 1) { + for (x = 0; x < srcWidth; x += 1) { + // Get the current pixel. + index = (y * srcWidth + x) << 2; + + old_r = dstPixels[index]; + old_g = dstPixels[index + 1]; + old_b = dstPixels[index + 2]; + + // Quantize using the color map + new_r = posterize[old_r]; + new_g = posterize[old_g]; + new_b = posterize[old_b]; + + // Set the current pixel. + dstPixels[index] = new_r; + dstPixels[index + 1] = new_g; + dstPixels[index + 2] = new_b; + + // Quantization errors + err_r = old_r - new_r; + err_g = old_g - new_g; + err_b = old_b - new_b; + + // Apply the matrix. + // x + 1, y + index += 1 << 2; + if (x < srcWidthMinus1) { + nbr_r = dstPixels[index] + A * err_r; + nbr_g = dstPixels[index + 1] + A * err_g; + nbr_b = dstPixels[index + 2] + A * err_b; + + dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0; + dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0; + dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0; + } + + // x - 1, y + 1 + index += (srcWidth - 2) << 2; + if (x > 0 && y < srcHeightMinus1) { + nbr_r = dstPixels[index] + B * err_r; + nbr_g = dstPixels[index + 1] + B * err_g; + nbr_b = dstPixels[index + 2] + B * err_b; + + dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0; + dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0; + dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0; + } + + // x, y + 1 + index += 1 << 2; + if (y < srcHeightMinus1) { + nbr_r = dstPixels[index] + C * err_r; + nbr_g = dstPixels[index + 1] + C * err_g; + nbr_b = dstPixels[index + 2] + C * err_b; + + dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0; + dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0; + dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0; + } + + // x + 1, y + 1 + index += 1 << 2; + if (x < srcWidthMinus1 && y < srcHeightMinus1) { + nbr_r = dstPixels[index] + D * err_r; + nbr_g = dstPixels[index + 1] + D * err_g; + nbr_b = dstPixels[index + 2] + D * err_b; + + dstPixels[index] = nbr_r > 255 ? 255 : nbr_r < 0 ? 0 : nbr_r | 0; + dstPixels[index + 1] = nbr_g > 255 ? 255 : nbr_g < 0 ? 0 : nbr_g | 0; + dstPixels[index + 2] = nbr_b > 255 ? 255 : nbr_b < 0 ? 0 : nbr_b | 0; + } + } + } + + return dstImageData; +}; + +ImageFilters.Edge = function (srcImageData) { + //pretty close to Fireworks 'Find Edges' effect + return this.ConvolutionFilter(srcImageData, 3, 3, [ + -1, -1, -1, + -1, 8, -1, + -1, -1, -1 + ]); +}; + +ImageFilters.Emboss = function (srcImageData) { + return this.ConvolutionFilter(srcImageData, 3, 3, [ + -2, -1, 0, + -1, 1, 1, + 0, 1, 2 + ]); +}; + +ImageFilters.Enrich = function (srcImageData) { + return this.ConvolutionFilter(srcImageData, 3, 3, [ + 0, -2, 0, + -2, 20, -2, + 0, -2, 0 + ], 10, -40); +}; + +ImageFilters.Flip = function (srcImageData, vertical) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var x, y, srcIndex, dstIndex, i; + + for (y = 0; y < srcHeight; y += 1) { + for (x = 0; x < srcWidth; x += 1) { + srcIndex = (y * srcWidth + x) << 2; + if (vertical) { + dstIndex = ((srcHeight - y - 1) * srcWidth + x) << 2; + } else { + dstIndex = (y * srcWidth + (srcWidth - x - 1)) << 2; + } + + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + } + } + + return dstImageData; +}; + +ImageFilters.Gamma = function (srcImageData, gamma) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + value = (255 * Math.pow(value / 255, 1 / gamma) + 0.5); + return value > 255 ? 255 : value + 0.5 | 0; + }); + + return dstImageData; +}; + +ImageFilters.GrayScale = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + for (var i = 0; i < srcLength; i += 4) { + var intensity = (srcPixels[i] * 19595 + srcPixels[i + 1] * 38470 + srcPixels[i + 2] * 7471) >> 16; + //var intensity = (srcPixels[i] * 0.3086 + srcPixels[i + 1] * 0.6094 + srcPixels[i + 2] * 0.0820) | 0; + dstPixels[i] = dstPixels[i + 1] = dstPixels[i + 2] = intensity; + dstPixels[i + 3] = srcPixels[i + 3]; + } + + return dstImageData; +}; + +/** + * @param hueDelta -180 <= n <= 180 + * @param satDelta -100 <= n <= 100 + * @param lightness -100 <= n <= 100 + */ +ImageFilters.HSLAdjustment = function (srcImageData, hueDelta, satDelta, lightness) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + hueDelta /= 360; + satDelta /= 100; + lightness /= 100; + + var rgbToHsl = this.utils.rgbToHsl; + var hslToRgb = this.utils.hslToRgb; + var h, s, l, hsl, rgb, i; + + for (i = 0; i < srcLength; i += 4) { + // convert to HSL + hsl = rgbToHsl(srcPixels[i], srcPixels[i + 1], srcPixels[i + 2]); + + // hue + h = hsl[0] + hueDelta; + while (h < 0) { + h += 1; + } + while (h > 1) { + h -= 1; + } + + // saturation + s = hsl[1] + hsl[1] * satDelta; + if (s < 0) { + s = 0; + } else if (s > 1) { + s = 1; + } + + // lightness + l = hsl[2]; + if (lightness > 0) { + l += (1 - l) * lightness; + } else if (lightness < 0) { + l += l * lightness; + } + + // convert back to rgb + rgb = hslToRgb(h, s, l); + + dstPixels[i] = rgb[0]; + dstPixels[i + 1] = rgb[1]; + dstPixels[i + 2] = rgb[2]; + dstPixels[i + 3] = srcPixels[i + 3]; + } + + return dstImageData; +}; + +ImageFilters.Invert = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + return 255 - value; + }); + + return dstImageData; +}; + +ImageFilters.Mosaic = function (srcImageData, blockSize) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var cols = Math.ceil(srcWidth / blockSize), + rows = Math.ceil(srcHeight / blockSize), + row, col, + x_start, x_end, y_start, y_end, + x, y, yIndex, index, size, + r, g, b, a; + + for (row = 0; row < rows; row += 1) { + y_start = row * blockSize; + y_end = y_start + blockSize; + + if (y_end > srcHeight) { + y_end = srcHeight; + } + + for (col = 0; col < cols; col += 1) { + x_start = col * blockSize; + x_end = x_start + blockSize; + + if (x_end > srcWidth) { + x_end = srcWidth; + } + + // get the average color from the src + r = g = b = a = 0; + size = (x_end - x_start) * (y_end - y_start); + + for (y = y_start; y < y_end; y += 1) { + yIndex = y * srcWidth; + + for (x = x_start; x < x_end; x += 1) { + index = (yIndex + x) << 2; + r += srcPixels[index]; + g += srcPixels[index + 1]; + b += srcPixels[index + 2]; + a += srcPixels[index + 3]; + } + } + + r = (r / size) + 0.5 | 0; + g = (g / size) + 0.5 | 0; + b = (b / size) + 0.5 | 0; + a = (a / size) + 0.5 | 0; + + // fill the dst with that color + for (y = y_start; y < y_end; y += 1) { + yIndex = y * srcWidth; + + for (x = x_start; x < x_end; x += 1) { + index = (yIndex + x) << 2; + dstPixels[index] = r; + dstPixels[index + 1] = g; + dstPixels[index + 2] = b; + dstPixels[index + 3] = a; + } + } + } + } + + return dstImageData; +}; + +/** + * @param range 1 <= n <= 5 + * @param levels 1 <= n <= 256 + */ +ImageFilters.Oil = function (srcImageData, range, levels) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var index = 0, + rh = [], + gh = [], + bh = [], + rt = [], + gt = [], + bt = [], + x, y, i, row, col, + rowIndex, colIndex, offset, srcIndex, + sr, sg, sb, ri, gi, bi, + r, g, b; + + for (y = 0; y < srcHeight; y += 1) { + for (x = 0; x < srcWidth; x += 1) { + for (i = 0; i < levels; i += 1) { + rh[i] = gh[i] = bh[i] = rt[i] = gt[i] = bt[i] = 0; + } + + for (row = -range; row <= range; row += 1) { + rowIndex = y + row; + + if (rowIndex < 0 || rowIndex >= srcHeight) { + continue; + } + + offset = rowIndex * srcWidth; + + for (col = -range; col <= range; col += 1) { + colIndex = x + col; + if (colIndex < 0 || colIndex >= srcWidth) { + continue; + } + + srcIndex = (offset + colIndex) << 2; + sr = srcPixels[srcIndex]; + sg = srcPixels[srcIndex + 1]; + sb = srcPixels[srcIndex + 2]; + ri = (sr * levels) >> 8; + gi = (sg * levels) >> 8; + bi = (sb * levels) >> 8; + rt[ri] += sr; + gt[gi] += sg; + bt[bi] += sb; + rh[ri] += 1; + gh[gi] += 1; + bh[bi] += 1; + } + } + + r = g = b = 0; + for (i = 1; i < levels; i += 1) { + if (rh[i] > rh[r]) { + r = i; + } + if (gh[i] > gh[g]) { + g = i; + } + if (bh[i] > bh[b]) { + b = i; + } + } + + dstPixels[index] = rt[r] / rh[r] | 0; + dstPixels[index + 1] = gt[g] / gh[g] | 0; + dstPixels[index + 2] = bt[b] / bh[b] | 0; + dstPixels[index + 3] = srcPixels[index + 3]; + index += 4; + } + } + + return dstImageData; +}; + +ImageFilters.OpacityFilter = function (srcImageData, opacity) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + for (var i = 0; i < srcLength; i += 4) { + dstPixels[i] = srcPixels[i]; + dstPixels[i + 1] = srcPixels[i + 1]; + dstPixels[i + 2] = srcPixels[i + 2]; + dstPixels[i + 3] = opacity; + } + + return dstImageData; +}; + +/** + * @param levels 2 <= n <= 255 + */ +ImageFilters.Posterize = function (srcImageData, levels) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + levels = levels < 2 ? 2 : levels > 255 ? 255 : levels; + + var levelMap = [], + levelsMinus1 = levels - 1, + j = 0, + k = 0, + i; + + for (i = 0; i < levels; i += 1) { + levelMap[i] = (255 * i) / levelsMinus1; + } + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + var ret = levelMap[j]; + + k += levels; + + if (k > 255) { + k -= 255; + j += 1; + } + + return ret; + }); + + return dstImageData; +}; + +/** + * @param scale 0.0 <= n <= 5.0 + */ +ImageFilters.Rescale = function (srcImageData, scale) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + value *= scale; + return (value > 255) ? 255 : value + 0.5 | 0; + }); + + return dstImageData; +}; + +/** + * Nearest neighbor + */ +ImageFilters.ResizeNearestNeighbor = function (srcImageData, width, height) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(width, height), + dstPixels = dstImageData.data; + + var xFactor = srcWidth / width, + yFactor = srcHeight / height, + dstIndex = 0, srcIndex, + x, y, offset; + + for (y = 0; y < height; y += 1) { + offset = ((y * yFactor) | 0) * srcWidth; + + for (x = 0; x < width; x += 1) { + srcIndex = (offset + x * xFactor) << 2; + + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + dstIndex += 4; + } + } + + return dstImageData; +}; + +/** + * Bilinear + */ +ImageFilters.Resize = function (srcImageData, width, height) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(width, height), + dstPixels = dstImageData.data; + + var xFactor = srcWidth / width, + yFactor = srcHeight / height, + dstIndex = 0, + x, y; + + for (y = 0; y < height; y += 1) { + for (x = 0; x < width; x += 1) { + this.utils.copyBilinear(srcPixels, x * xFactor, y * yFactor, srcWidth, srcHeight, dstPixels, dstIndex, 0); + dstIndex += 4; + } + } + + return dstImageData; +}; + + +/** + * faster resizing using the builtin context.scale() + * the resizing algorithm may be different between browsers + * this might not work if the image is transparent. + * to fix that we probably need two contexts + */ +ImageFilters.ResizeBuiltin = function (srcImageData, width, height) { + var srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + canvas = this.utils.getSampleCanvas(), + context = this.utils.getSampleContext(), + dstImageData; + + canvas.width = Math.max(srcWidth, width); + canvas.height = Math.max(srcHeight, height); + context.save(); + + context.putImageData(srcImageData, 0, 0); + context.scale(width / srcWidth, height / srcHeight); + context.drawImage(canvas, 0, 0); + + dstImageData = context.getImageData(0, 0, width, height); + + context.restore(); + canvas.width = 0; + canvas.height = 0; + + return dstImageData; +}; + +ImageFilters.Sepia = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + var r, g, b, i, value; + + for (i = 0; i < srcLength; i += 4) { + r = srcPixels[i]; + g = srcPixels[i + 1]; + b = srcPixels[i + 2]; + + dstPixels[i] = (value = r * 0.393 + g * 0.769 + b * 0.189) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0; + dstPixels[i + 1] = (value = r * 0.349 + g * 0.686 + b * 0.168) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0; + dstPixels[i + 2] = (value = r * 0.272 + g * 0.534 + b * 0.131) > 255 ? 255 : value < 0 ? 0 : value + 0.5 | 0; + dstPixels[i + 3] = srcPixels[i + 3]; + } + + return dstImageData; +}; + +/** + * @param factor 1 <= n + */ +ImageFilters.Sharpen = function (srcImageData, factor) { + //Convolution formula from VIGRA + return this.ConvolutionFilter(srcImageData, 3, 3, [ + -factor / 16, -factor / 8, -factor / 16, + -factor / 8, factor * 0.75 + 1, -factor / 8, + -factor / 16, -factor / 8, -factor / 16 + ]); +}; + +ImageFilters.Solarize = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + this.utils.mapRGB(srcPixels, dstPixels, function (value) { + return value > 127 ? (value - 127.5) * 2 : (127.5 - value) * 2; + }); + + return dstImageData; +}; + +ImageFilters.Transpose = function (srcImageData) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcHeight, srcWidth), + dstPixels = dstImageData.data; + + var srcIndex, dstIndex; + + for (y = 0; y < srcHeight; y += 1) { + for (x = 0; x < srcWidth; x += 1) { + srcIndex = (y * srcWidth + x) << 2; + dstIndex = (x * srcHeight + y) << 2; + + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + } + } + + return dstImageData; +}; + +/** + * @param centerX 0.0 <= n <= 1.0 + * @param centerY 0.0 <= n <= 1.0 + * @param radius + * @param angle(degree) + * @param smooth + */ +ImageFilters.Twril = function (srcImageData, centerX, centerY, radius, angle, edge, smooth) { + var srcPixels = srcImageData.data, + srcWidth = srcImageData.width, + srcHeight = srcImageData.height, + srcLength = srcPixels.length, + dstImageData = this.utils.createImageData(srcWidth, srcHeight), + dstPixels = dstImageData.data; + + //convert position to px + centerX = srcWidth * centerX; + centerY = srcHeight * centerY; + + // degree to radian + angle *= (Math.PI / 180); + + var radius2 = radius * radius, + max_y = srcHeight - 1, + max_x = srcWidth - 1, + dstIndex = 0, + x, y, dx, dy, distance, a, tx, ty, srcIndex, pixel, i; + + for (y = 0; y < srcHeight; y += 1) { + for (x = 0; x < srcWidth; x += 1) { + dx = x - centerX; + dy = y - centerY; + distance = dx * dx + dy * dy; + + if (distance > radius2) { + // out of the effected area. just copy the pixel + dstPixels[dstIndex] = srcPixels[dstIndex]; + dstPixels[dstIndex + 1] = srcPixels[dstIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[dstIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[dstIndex + 3]; + } else { + // main formula + distance = Math.sqrt(distance); + a = Math.atan2(dy, dx) + (angle * (radius - distance)) / radius; + tx = centerX + distance * Math.cos(a); + ty = centerY + distance * Math.sin(a); + + // copy target pixel + if (smooth) { + // bilinear + this.utils.copyBilinear(srcPixels, tx, ty, srcWidth, srcHeight, dstPixels, dstIndex, edge); + } else { + // nearest neighbor + // round tx, ty + srcIndex = ((ty + 0.5 | 0) * srcWidth + (tx + 0.5 | 0)) << 2; + dstPixels[dstIndex] = srcPixels[srcIndex]; + dstPixels[dstIndex + 1] = srcPixels[srcIndex + 1]; + dstPixels[dstIndex + 2] = srcPixels[srcIndex + 2]; + dstPixels[dstIndex + 3] = srcPixels[srcIndex + 3]; + } + } + + dstIndex += 4; + } + } + + return dstImageData; +}; + +export default ImageFilters; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/jquery.translate.js b/paintplus/frontend/src/js/libs/jquery.translate.js new file mode 100644 index 0000000..b5cfa21 --- /dev/null +++ b/paintplus/frontend/src/js/libs/jquery.translate.js @@ -0,0 +1,65 @@ +// https://github.com/jorgejeferson/translate.js/tree/39be8237666a76035fc210a28d8e431f1416579e +(function ($) { + $.fn.translate = function (options) { + var that = this; //a reference to ourselves + var settings = { + css: "trn", + attrs: ["alt", "placeholder", "title"], + lang: "pt", + langDefault: "pt", + }; + settings = $.extend(settings, options || {}); + if (settings.css.lastIndexOf(".", 0) !== 0) { //doesn't start with '.' + settings.css = "." + settings.css; + } + var t = settings.t; + //public methods + this.lang = function (l) { + if (l) { + settings.lang = l; + this.translate(settings); //translate everything + } + return settings.lang; + }; + this.get = function (index) { + var res = index; + + try { + res = t[index][settings.lang]; + } + catch (err) { //not found, return index + return index; + } + if (res) { + return res; + } + else { + return index; + } + }; + this.g = this.get; + //main + this.find(settings.css).each(function (i) { + var $this = $(this); + + var trn_key = $this.attr("data-trn-key"); + if (!trn_key) { + trn_key = $this.html(); + $this.attr("data-trn-key", trn_key); + } + // Filtering attr + $.each(this.attributes, function () { + if ($.inArray(this.name, settings.attrs) !== -1) { + var trn_attr_key = $this.attr("data-trn-attr"); + if (!trn_attr_key) { + trn_attr_key = $this.attr(this.name); + $this.attr("data-trn-attr", trn_attr_key); + } + $this.attr(this.name, that.get(trn_attr_key)); + } + }); + $this.html(that.get(trn_key)); + }); + return this; + }; +})(jQuery); diff --git a/paintplus/frontend/src/js/libs/pdf-writer.js b/paintplus/frontend/src/js/libs/pdf-writer.js new file mode 100644 index 0000000..93433e2 --- /dev/null +++ b/paintplus/frontend/src/js/libs/pdf-writer.js @@ -0,0 +1,240 @@ +/** + * Minimal pure-JS PDF writer for image export. + * + * Supports: + * - Single-page and multipage (one page per canvas) + * - RGB color space: images JPEG-encoded (browser-native, small files) + * - CMYK color space: raw DeviceCMYK bytes (print-ready, no alpha) + * + * PDF-1.4 structure used. No external dependencies. + * + * Usage: + * PdfWriter.fromCanvases(canvases, { colorMode: 'rgb'|'cmyk', quality: 0.9, dpi: 300 }) + * .then(blob => FileSaver.saveAs(blob, 'file.pdf')); + */ + +import { rgbToCmyk } from './tiff-writer.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Encode a JS string to a Uint8Array of bytes (Latin-1 safe). */ +function strBytes(s) { + var a = new Uint8Array(s.length); + for (var i = 0; i < s.length; i++) a[i] = s.charCodeAt(i) & 0xff; + return a; +} + +/** Concatenate multiple Uint8Arrays / ArrayBuffers into one Uint8Array. */ +function concat(parts) { + var total = 0; + for (var i = 0; i < parts.length; i++) + total += parts[i].byteLength || parts[i].length; + var out = new Uint8Array(total), pos = 0; + for (var i = 0; i < parts.length; i++) { + var p = parts[i] instanceof ArrayBuffer ? new Uint8Array(parts[i]) : parts[i]; + out.set(p, pos); + pos += p.length; + } + return out; +} + +/** canvas → raw CMYK Uint8Array (W*H*4 bytes, alpha discarded). */ +function canvasToCmykBytes(canvas) { + var idata = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; + var out = new Uint8Array(canvas.width * canvas.height * 4); + for (var px = 0, i = 0, len = idata.length; px < len; px += 4, i += 4) { + var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]); + out[i] = cmyk[0]; + out[i + 1] = cmyk[1]; + out[i + 2] = cmyk[2]; + out[i + 3] = cmyk[3]; + } + return out; +} + +/** canvas → JPEG Uint8Array via browser encoding. Returns a Promise. */ +function canvasToJpegBytes(canvas, quality) { + return new Promise(function(resolve) { + canvas.toBlob(function(blob) { + blob.arrayBuffer().then(function(buf) { + resolve(new Uint8Array(buf)); + }); + }, 'image/jpeg', quality || 0.92); + }); +} + +// --------------------------------------------------------------------------- +// PDF object builder +// --------------------------------------------------------------------------- + +/** + * Build a complete PDF byte stream for an array of pages. + * + * @param {Array<{width, height, colorSpace, imgBytes, filter}>} pages + * @param {number} dpi — used for MediaBox sizing (px → pt: pt = px * 72 / dpi) + * @returns {Uint8Array} + */ +function buildPDF(pages, dpi) { + dpi = dpi || 300; + var px2pt = 72 / dpi; + + // Object registry: we'll collect byte-offset of each object for xref. + var objs = []; // each element is the raw bytes of "N 0 obj ... endobj\n" + var objNums = {}; // logical name → 1-based index + + function addObj(name, content) { + var n = objs.length + 1; + if (name) objNums[name] = n; + var s = n + ' 0 obj\n' + content + '\nendobj\n'; + objs.push(strBytes(s)); + return n; + } + + function addStreamObj(name, dict, dataBytes) { + var n = objs.length + 1; + if (name) objNums[name] = n; + var header = n + ' 0 obj\n' + dict + '\nstream\n'; + var footer = '\nendstream\nendobj\n'; + var combined = concat([strBytes(header), dataBytes, strBytes(footer)]); + objs.push(combined); + return n; + } + + // 1. Catalog + addObj('catalog', '<< /Type /Catalog /Pages 2 0 R >>'); + + // 2. Pages (placeholder — children added later) + var pagesIdx = objs.length + 1; + addObj('pages', ''); // placeholder + + // 3. Per-page objects + var pageObjNums = []; + for (var p = 0; p < pages.length; p++) { + var pg = pages[p]; + var W_pt = (pg.width * px2pt).toFixed(3); + var H_pt = (pg.height * px2pt).toFixed(3); + var imgName = 'Im' + (p + 1); + var imgIdx = objs.length + 2; // will be added after content stream + + // Content stream: scale and paint image + var contentStr = 'q ' + W_pt + ' 0 0 ' + H_pt + ' 0 0 cm /' + imgName + ' Do Q'; + var contentNum = addStreamObj(null, + '<< /Length ' + contentStr.length + ' >>', + strBytes(contentStr)); + + // Image XObject + var samples = pg.colorSpace === 'DeviceCMYK' ? 4 : 3; + var imgDict = '<< /Type /XObject /Subtype /Image' + + ' /Width ' + pg.width + + ' /Height ' + pg.height + + ' /ColorSpace /' + pg.colorSpace + + ' /BitsPerComponent 8' + + (pg.filter ? ' /Filter /' + pg.filter : '') + + ' /Length ' + pg.imgBytes.length + + ' >>'; + var imgNum = addStreamObj(null, imgDict, pg.imgBytes); + + // Page object + var pageNum = addObj(null, + '<< /Type /Page /Parent ' + pagesIdx + ' 0 R' + + ' /MediaBox [0 0 ' + W_pt + ' ' + H_pt + ']' + + ' /Contents ' + contentNum + ' 0 R' + + ' /Resources << /XObject << /' + imgName + ' ' + imgNum + ' 0 R >> >>' + + ' >>'); + pageObjNums.push(pageNum); + } + + // Fill in Pages object properly + var kidsStr = pageObjNums.map(function(n) { return n + ' 0 R'; }).join(' '); + var pagesContent = '<< /Type /Pages /Kids [' + kidsStr + '] /Count ' + pages.length + ' >>'; + objs[pagesIdx - 1] = strBytes(pagesIdx + ' 0 obj\n' + pagesContent + '\nendobj\n'); + + // ---- Assemble file ---- + var header = strBytes('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n'); // binary hint comment + var offsets = []; + var parts = [header]; + var bytePos = header.length; + + for (var i = 0; i < objs.length; i++) { + offsets.push(bytePos); + parts.push(objs[i]); + bytePos += objs[i].length; + } + + // xref table + var xrefOffset = bytePos; + var xrefLines = 'xref\n0 ' + (objs.length + 1) + '\n'; + xrefLines += '0000000000 65535 f \n'; + for (var i = 0; i < offsets.length; i++) { + xrefLines += String(offsets[i]).padStart(10, '0') + ' 00000 n \n'; + } + parts.push(strBytes(xrefLines)); + + // trailer + var trailerStr = 'trailer\n<< /Size ' + (objs.length + 1) + + ' /Root 1 0 R >>\nstartxref\n' + xrefOffset + '\n%%EOF\n'; + parts.push(strBytes(trailerStr)); + + return concat(parts); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +var PdfWriter = { + + /** + * Export an array of canvases as a PDF. + * + * @param {HTMLCanvasElement|HTMLCanvasElement[]} canvases + * @param {object} [opts] + * @param {'rgb'|'cmyk'} [opts.colorMode='rgb'] + * @param {number} [opts.quality=0.92] JPEG quality for RGB mode (0–1) + * @param {number} [opts.dpi=300] dots-per-inch for page sizing + * @returns {Promise} + */ + fromCanvases: function(canvases, opts) { + if (!Array.isArray(canvases)) canvases = [canvases]; + opts = opts || {}; + var colorMode = opts.colorMode === 'cmyk' ? 'cmyk' : 'rgb'; + var dpi = +(opts.dpi || 300) | 0; + var quality = opts.quality != null ? opts.quality : 0.92; + + if (colorMode === 'cmyk') { + // Synchronous path: raw CMYK bytes + var pages = canvases.map(function(cv) { + return { + width: cv.width, + height: cv.height, + colorSpace: 'DeviceCMYK', + filter: null, + imgBytes: canvasToCmykBytes(cv), + }; + }); + var pdfBytes = buildPDF(pages, dpi); + return Promise.resolve(new Blob([pdfBytes], { type: 'application/pdf' })); + } else { + // Async path: JPEG-encode each canvas + var promises = canvases.map(function(cv) { + return canvasToJpegBytes(cv, quality).then(function(bytes) { + return { + width: cv.width, + height: cv.height, + colorSpace: 'DeviceRGB', + filter: 'DCTDecode', + imgBytes: bytes, + }; + }); + }); + return Promise.all(promises).then(function(pages) { + var pdfBytes = buildPDF(pages, dpi); + return new Blob([pdfBytes], { type: 'application/pdf' }); + }); + } + }, +}; + +export default PdfWriter; diff --git a/paintplus/frontend/src/js/libs/popup.js b/paintplus/frontend/src/js/libs/popup.js new file mode 100644 index 0000000..bdcdce4 --- /dev/null +++ b/paintplus/frontend/src/js/libs/popup.js @@ -0,0 +1,676 @@ +/** + * user dialogs library + * + * @author ViliusL + * + * Usage: + * + * import Dialog_class from './libs/popup.js'; + * var POP = new popup(); + * + * var settings = { + * title: 'Differences', + * comment: '', + * preview: true, + * className: '', + * params: [ + * {name: "param1", title: "Parameter #1:", value: "111"}, + * {name: "param2", title: "Parameter #2:", value: "222"}, + * ], + * on_load: function(params){...}, + * on_change: function(params, canvas_preview, w, h){...}, + * on_finish: function(params){...}, + * on_cancel: function(params){...}, + * }; + * this.POP.show(settings); + * + * Params types: + * - name type example + * - --------------------------------------------------------------- + * - name string 'parameter1' + * - title string 'enter value:' + * - type string 'select', 'textarea', 'color' + * - value string '314' + * - values array fo strings ['one', 'two', 'three'] + * - range numbers interval [0, 255] + * - step int/float 1 + * - placeholder text 'enter number here' + * - html html text 'bold' + * - function function 'custom_function' + */ +import './../../css/popup.css'; +import Base_layers_class from './../core/base-layers.js'; +import Base_gui_class from './../core/base-gui.js'; +import Tools_translate_class from './../modules/tools/translate.js'; + +var template = ` + +
    + +

    +
    +
    +
    +
    +
    + + +
    +`; + +class Dialog_class { + + constructor() { + if (!window.POP) { + window.POP = this; + } + + this.previousPOP = null; + this.el = null; + this.eventHandles = []; + this.active = false; + this.title = null; + this.onfinish = false; + this.oncancel = false; + this.preview = false; + this.preview_padding = 0; + this.onload = false; + this.onchange = false; + this.width_mini = 225; + this.height_mini = 200; + this.id = 0; + this.parameters = []; + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Tools_translate = new Tools_translate_class(); + this.last_params_hash = ''; + this.layer_active_small = document.createElement("canvas"); + this.layer_active_small_ctx = this.layer_active_small.getContext("2d"); + this.caller = null; + this.resize_clicked = {x: null, y: null} + this.element_offset = {x: null, y: null} + } + + /** + * shows dialog + * + * @param {array} config + */ + show(config) { + this.previousPOP = window.POP; + window.POP = this; + + if (this.active == true) { + this.hide(); + } + + this.title = config.title || ''; + this.parameters = config.params || []; + this.onfinish = config.on_finish || false; + this.oncancel = config.on_cancel || false; + this.preview = config.preview || false; + this.preview_padding = config.preview_padding || 0; + this.onchange = config.on_change || false; + this.onload = config.on_load || false; + this.className = config.className || ''; + this.comment = config.comment || ''; + + //reset position + this.el = document.createElement('div'); + this.el.classList = 'popup'; + this.el.role = 'dialog'; + document.querySelector('#popups').appendChild(this.el); + this.el.style.top = null; + this.el.style.left = null; + + this.show_action(); + this.set_events(); + } + + /** + * hides dialog + * + * @param {boolean} success + * @returns {undefined} + */ + hide(success) { + window.POP = this.previousPOP; + var params = this.get_params(); + + if (success === false && this.oncancel) { + this.oncancel(params); + } + if (this.el && this.el.parentNode) { + this.el.parentNode.removeChild(this.el); + } + this.parameters = []; + this.active = false; + this.preview = false; + this.preview_padding = 0; + this.onload = false; + this.onchange = false; + this.title = null; + this.className = ''; + this.comment = ''; + this.onfinish = false; + this.oncancel = false; + + this.remove_events(); + } + + get_active_instances() { + return document.getElementById('popups').children.length; + } + + /* ----------------- private functions ---------------------------------- */ + + addEventListener(target, type, listener, options) { + target.addEventListener(type, listener, options); + const handle = { + target, type, listener, + remove() { + target.removeEventListener(type, listener); + } + }; + this.eventHandles.push(handle); + } + + set_events() { + this.addEventListener(document, 'keydown', (event) => { + var code = event.code; + + if (code == "Escape") { + //escape + this.hide(false); + } + }, false); + + //register events + this.addEventListener(document, 'mousedown', (event) => { + if(event.target != this.el.querySelector('h2')) + return; + event.preventDefault(); + this.resize_clicked.x = event.pageX; + this.resize_clicked.y = event.pageY; + + var target = this.el; + this.element_offset.x = target.offsetLeft; + this.element_offset.y = target.offsetTop; + }, false); + + this.addEventListener(document, 'mousemove', (event) => { + if(this.resize_clicked.x != null){ + var dx = this.resize_clicked.x - event.pageX; + var dy = this.resize_clicked.y - event.pageY; + + var target = this.el; + target.style.left = (this.element_offset.x - dx) + "px"; + target.style.top = (this.element_offset.y - dy) + "px"; + } + }, false); + + this.addEventListener(document, 'mouseup', (event) => { + if(event.target != this.el.querySelector('h2')) + return; + event.preventDefault(); + this.resize_clicked.x = null; + this.resize_clicked.y = null; + }, false); + + this.addEventListener(window, 'resize', (event) => { + var target = this.el; + target.style.top = null; + target.style.left = null; + }, false); + } + + remove_events() { + for (let handle of this.eventHandles) { + handle.remove(); + } + this.eventHandles = []; + } + + onChangeEvent(e) { + var params = this.get_params(); + + var hash = JSON.stringify(params); + if (this.last_params_hash == hash && this.onchange == false) { + //nothing changed + return; + } + this.last_params_hash = hash; + + if (this.onchange != false) { + if (this.preview != false) { + var canvas_right = this.el.querySelector('[data-id="pop_post"]'); + var ctx_right = canvas_right.getContext("2d"); + + ctx_right.clearRect(0, 0, this.width_mini, this.height_mini); + ctx_right.drawImage(this.layer_active_small, + this.preview_padding, this.preview_padding, + this.width_mini - this.preview_padding * 2, this.height_mini - this.preview_padding * 2 + ); + + this.onchange(params, ctx_right, this.width_mini, this.height_mini, canvas_right); + } + else { + this.onchange(params); + } + } + } + + //renders preview. If input=range supported, is called on every param update - must be fast... + preview_handler(e) { + if (this.preview !== false) { + this.onChangeEvent(e); + } + } + + //OK pressed - prepare data and call handlers + save() { + var params = this.get_params(); + + if (this.onfinish) { + this.onfinish(params); + } + + this.hide(true); + } + + //"Cancel" pressed + cancel() { + if (this.oncancel) { + var params = this.get_params(); + this.oncancel(params); + } + } + + get_params() { + var response = {}; + if(this.el == undefined){ + return null; + } + var inputs = this.el.querySelectorAll('input'); + for (var i = 0; i < inputs.length; i++) { + if (inputs[i].id.substr(0, 9) == 'pop_data_') { + var key = inputs[i].id.substr(9); + if (this.strpos(key, "_poptmp") != false) + key = key.substring(0, this.strpos(key, "_poptmp")); + var value = inputs[i].value; + if (inputs[i].type == 'radio') { + if (inputs[i].checked == true) + response[key] = value; + } + else if (inputs[i].type == 'number') { + response[key] = parseFloat(value); + } + else if (inputs[i].type == 'checkbox') { + if (inputs[i].checked == true) + response[key] = true; + else + response[key] = false; + } + else if (inputs[i].type == 'range') { + response[key] = parseFloat(value); + } + else { + response[key] = value; + } + + } + } + var selects = this.el.querySelectorAll('select'); + for (var i = 0; i < selects.length; i++) { + if (selects[i].id.substr(0, 9) == 'pop_data_') { + var key = selects[i].id.substr(9); + response[key] = selects[i].value; + } + } + var textareas = this.el.querySelectorAll('textarea'); + for (var i = 0; i < textareas.length; i++) { + if (textareas[i].id.substr(0, 9) == 'pop_data_') { + var key = textareas[i].id.substr(9); + response[key] = textareas[i].value; + } + } + + return response; + } + + /** + * show popup window. + * used strings: "Ok", "Cancel", "Preview" + */ + show_action() { + this.id = this.getRandomInt(0, 999999999); + if (this.active == true) { + this.hide(); + return false; + } + this.active = true; + + //build content + var html_pretitle_area = ''; + var html_preview_content = ''; + var html_params = ''; + + //preview area + if (this.preview !== false) { + html_preview_content += '
    '; + html_preview_content += ''; + html_preview_content += '
    '; + html_preview_content += ' '; + html_preview_content += ' '; + html_preview_content += '
    '; + html_preview_content += '
    '; + } + + //generate params + html_params += this.generateParamsHtml(); + + this.el.innerHTML = template; + this.el.querySelector('[data-id="pretitle_area"]').innerHTML = html_pretitle_area; + this.el.querySelector('[data-id="popup_title"]').innerHTML = this.title; + this.el.querySelector('[data-id="popup_comment"]').innerHTML = this.comment; + this.el.querySelector('[data-id="preview_content"]').innerHTML = html_preview_content; + this.el.querySelector('[data-id="params_content"]').innerHTML = html_params; + if (this.onfinish != false) { + this.el.querySelector('[data-id="popup_cancel"]').style.display = ''; + } + else { + this.el.querySelector('[data-id="popup_cancel"]').style.display = 'none'; + } + + this.el.style.display = "block"; + if (this.className) { + this.el.classList.add(this.className); + } + + //replace color inputs + this.el.querySelectorAll('input[type="color"]').forEach((colorInput) => { + const id = colorInput.getAttribute('id'); + colorInput.removeAttribute('id'); + $(colorInput) + .uiColorInput({ inputId: id }) + .on('change', (e) => { + this.onChangeEvent(e); + }); + }); + + //events + this.el.querySelector('[data-id="popup_ok"]').addEventListener('click', (event) => { + this.save(); + }); + this.el.querySelector('[data-id="popup_cancel"]').addEventListener('click', (event) => { + this.hide(false); + }); + this.el.querySelector('[data-id="popup_close"]').addEventListener('click', (event) => { + this.hide(false); + }); + var targets = this.el.querySelectorAll('input'); + for (var i = 0; i < targets.length; i++) { + targets[i].addEventListener('keyup', (event) => { + this.onkeyup(event); + }); + } + + //onload + if (this.onload) { + var params = this.get_params(); + this.onload(params, this); + } + + //load preview + if (this.preview !== false) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(); + + //draw original image + var canvas_left = this.el.querySelector('[data-id="pop_pre"]'); + var pop_pre = canvas_left.getContext("2d"); + pop_pre.clearRect(0, 0, this.width_mini, this.height_mini); + pop_pre.rect(0, 0, this.width_mini, this.height_mini); + pop_pre.fillStyle = "#ffffff"; + pop_pre.fill(); + this.draw_background(pop_pre, this.width_mini, this.height_mini, 10); + + pop_pre.scale(this.width_mini / canvas.width, this.height_mini / canvas.height); + pop_pre.drawImage(canvas, 0, 0); + pop_pre.scale(1, 1); + + //prepare temp canvas for faster repaint + this.layer_active_small.width = POP.width_mini; + this.layer_active_small.height = POP.height_mini; + this.layer_active_small_ctx.scale(this.width_mini / canvas.width, this.height_mini / canvas.height); + this.layer_active_small_ctx.drawImage(canvas, 0, 0); + this.layer_active_small_ctx.scale(1, 1); + + //draw right background + var canvas_right_back = this.el.querySelector('[data-id="pop_post_back"]').getContext("2d"); + this.draw_background(canvas_right_back, this.width_mini, this.height_mini, 10); + + //copy to right side + var canvas_right = this.el.querySelector('[data-id="pop_post"]').getContext("2d"); + canvas_right.clearRect(0, 0, this.width_mini, this.height_mini); + canvas_right.drawImage(canvas_left, + this.preview_padding, this.preview_padding, + this.width_mini - this.preview_padding * 2, this.height_mini - this.preview_padding * 2); + + //prepare temp canvas + this.preview_handler(); + } + + //call translation again to translate popup + var lang = this.Base_gui.get_language(); + this.Tools_translate.translate(lang); + } + + generateParamsHtml() { + var html = ''; + var title = null; + for (var i in this.parameters) { + var parameter = this.parameters[i]; + + html += ''; + if (title != 'Error' && parameter.title != undefined) + html += ''; + if (parameter.name != undefined) { + if (parameter.values != undefined) { + if (parameter.values.length > 10 || parameter.type == 'select') { + //drop down + html += ''; + } + else { + //radio + html += ''; + } + } + else if (parameter.value != undefined) { + //input, range, textarea, color + var step = 1; + if (parameter.step != undefined) + step = parameter.step; + if (parameter.range != undefined) { + //range + html += ''; + html += ''; + } + else if (parameter.type == 'color') { + //color + html += ''; + } + else if (typeof parameter.value == 'boolean') { + var checked = ''; + if (parameter.value === true) + checked = 'checked'; + html += ''; + } + else { + //input or textarea + if (parameter.placeholder == undefined) + parameter.placeholder = ''; + if (parameter.type == 'textarea') { + //textarea + html += ''; + } + else { + //text or number + var input_type = "text"; + if (parameter.placeholder != '' && !isNaN(parameter.placeholder)) + input_type = 'number'; + if (parameter.value != undefined && typeof parameter.value == 'number') + input_type = 'number'; + + var comment_html = ''; + if (typeof parameter.comment !== 'undefined') { + comment_html = '' + parameter.comment + ''; + } + + html += ''; + } + } + } + } + else if (parameter.function != undefined) { + //custom function + var result; + result = parameter.function(); + html += ''; + } + else if (parameter.html != undefined) { + //html + html += ''; + } + else if (parameter.title == undefined) { + //gap + html += ''; + } + else { + //locked fields without name + var str = "" + parameter.value; + var id_tmp = parameter.title.toLowerCase().replace(/[^\w]+/g, '').replace(/ +/g, '-'); + id_tmp = id_tmp.substring(0, 10); + if (str.length < 40) + html += ''; + else + html += ''; + } + html += ''; + } + html += '
    '; + + return html; + } + + //on key press inside input text + onkeyup(event) { + if (event.key == 'Enter') { + if (event.target.hasAttribute('data-prevent-submission')) { + event.preventDefault(); + } else { + this.save(); + } + } + } + + getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + + strpos(haystack, needle, offset) { + var i = (haystack + '').indexOf(needle, (offset || 0)); + return i === -1 ? false : i; + } + + draw_background(canvas, W, H, gap, force) { + var transparent = this.Base_gui.get_transparency_support(); + + if (transparent == false && force == undefined) { + canvas.beginPath(); + canvas.rect(0, 0, W, H); + canvas.fillStyle = "#ffffff"; + canvas.fill(); + return false; + } + if (gap == undefined) + gap = 10; + var fill = true; + for (var i = 0; i < W; i = i + gap) { + if (i % (gap * 2) == 0) + fill = true; + else + fill = false; + for (var j = 0; j < H; j = j + gap) { + if (fill == true) { + canvas.fillStyle = '#eeeeee'; + canvas.fillRect(i, j, gap, gap); + fill = false; + } + else + fill = true; + } + } + } + +} + +export default Dialog_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/progress_overlay.js b/paintplus/frontend/src/js/libs/progress_overlay.js new file mode 100644 index 0000000..7792e4c --- /dev/null +++ b/paintplus/frontend/src/js/libs/progress_overlay.js @@ -0,0 +1,180 @@ +/** + * ProgressOverlay — shared animated progress indicator for long AI operations. + * + * Usage: + * import { showProgress, updateProgress, hideProgress } from './progress_overlay.js'; + * + * showProgress('Generating image…'); + * updateProgress(50, 'Denoising step 15/30…'); // optional step updates + * hideProgress(); + * + * When you don't have real step counts, call showProgress() and hideProgress() only — + * the bar animates automatically with a shimmer to signal activity. + */ + +var _overlay = null; +var _bar = null; +var _label = null; +var _shimmerAnim = null; +var _fakeTimer = null; +var _currentPct = 0; + +// ── SSE progress connection ─────────────────────────────────────────────────── + +var _sse = null; + +/** + * Open an EventSource to /api/generate/progress and drive the bar with real + * denoising step counts from the local GPU pipeline. + * + * @param {string} pipeType - 'txt2img' | 'inpaint' | 'img2img' + * @param {string} baseUrl - window.API_BASE_URL or '' + */ +export function connectProgressSSE(pipeType, baseUrl) { + disconnectProgressSSE(); + try { + var url = (baseUrl || '') + '/api/generate/progress'; + _sse = new EventSource(url); + _sse.onmessage = (e) => { + try { + var states = JSON.parse(e.data); + var s = Array.isArray(states) + ? states.find(st => st.pipeline === pipeType) + : null; + if (s && s.state === 'running' && s.total_steps) { + var pct = Math.round(s.step / s.total_steps * 85); + updateProgress(pct, s.message || `Step ${s.step} / ${s.total_steps}`); + } + } catch { /* malformed event — ignore */ } + }; + _sse.onerror = () => disconnectProgressSSE(); + } catch { /* SSE not supported */ } +} + +export function disconnectProgressSSE() { + if (_sse) { _sse.close(); _sse = null; } +} + +// ── Progress overlay ────────────────────────────────────────────────────────── + +export function showProgress(message, estimatedSeconds) { + hideProgress(); + + _currentPct = 0; + + // ── Backdrop ────────────────────────────────────────────────────────────── + _overlay = document.createElement('div'); + _overlay.id = 'ai-progress-overlay'; + _overlay.style.cssText = [ + 'position:fixed', 'inset:0', 'z-index:99999', + 'display:flex', 'flex-direction:column', + 'align-items:center', 'justify-content:center', + 'background:rgba(0,0,0,0.55)', + 'backdrop-filter:blur(2px)', + '-webkit-backdrop-filter:blur(2px)', + ].join(';'); + + // ── Card ────────────────────────────────────────────────────────────────── + var card = document.createElement('div'); + card.style.cssText = [ + 'background:#1a1a2e', + 'border:1px solid #3a3a6a', + 'border-radius:14px', + 'padding:28px 36px', + 'min-width:320px', 'max-width:480px', + 'box-shadow:0 12px 48px rgba(0,0,0,0.8)', + 'display:flex', 'flex-direction:column', 'gap:14px', + 'text-align:center', + ].join(';'); + + // ── Label ───────────────────────────────────────────────────────────────── + _label = document.createElement('div'); + _label.textContent = message || 'Processing…'; + _label.style.cssText = 'font-family:sans-serif;font-size:13px;color:#c0c0e0;line-height:1.4;min-height:2.8em'; + + // ── Track ───────────────────────────────────────────────────────────────── + var track = document.createElement('div'); + track.style.cssText = [ + 'width:100%', 'height:6px', + 'background:#0f0f2a', + 'border-radius:3px', + 'overflow:hidden', + 'position:relative', + ].join(';'); + + // ── Shimmer (indeterminate stripe) ──────────────────────────────────────── + var shimmer = document.createElement('div'); + shimmer.style.cssText = [ + 'position:absolute', 'inset:0', + 'background:linear-gradient(90deg,transparent 0%,rgba(120,120,255,0.25) 50%,transparent 100%)', + 'transform:translateX(-100%)', + 'will-change:transform', + ].join(';'); + + // ── Filled bar ──────────────────────────────────────────────────────────── + _bar = document.createElement('div'); + _bar.style.cssText = [ + 'position:absolute', 'inset-block:0', 'left:0', + 'width:0%', + 'background:linear-gradient(90deg,#5577ff,#88aaff)', + 'border-radius:3px', + 'transition:width 0.35s ease', + ].join(';'); + + // ── Cancel hint ─────────────────────────────────────────────────────────── + var hint = document.createElement('div'); + hint.textContent = 'Press Esc to cancel'; + hint.style.cssText = 'font-family:sans-serif;font-size:10px;color:#444;margin-top:2px'; + + track.appendChild(shimmer); + track.appendChild(_bar); + card.appendChild(_label); + card.appendChild(track); + card.appendChild(hint); + _overlay.appendChild(card); + document.body.appendChild(_overlay); + + // Animate shimmer + var pos = -100; + _shimmerAnim = setInterval(() => { + pos += 2.5; + if (pos > 200) pos = -100; + shimmer.style.transform = `translateX(${pos}%)`; + }, 16); + + // Fake progress that creeps toward 90% if no real steps given + if (estimatedSeconds) { + var totalMs = estimatedSeconds * 1000; + var step = 90 / (totalMs / 200); + _fakeTimer = setInterval(() => { + if (_currentPct < 90) { + _currentPct = Math.min(90, _currentPct + step); + _bar.style.width = _currentPct + '%'; + } + }, 200); + } + + // Esc to cancel + _overlay._escHandler = (e) => { if (e.key === 'Escape') hideProgress(); }; + document.addEventListener('keydown', _overlay._escHandler); +} + +export function updateProgress(pct, message) { + if (!_overlay) return; + _currentPct = Math.max(_currentPct, Math.min(100, pct)); + if (_bar) _bar.style.width = _currentPct + '%'; + if (_label && message) _label.textContent = message; +} + +export function hideProgress() { + if (_shimmerAnim) { clearInterval(_shimmerAnim); _shimmerAnim = null; } + if (_fakeTimer) { clearInterval(_fakeTimer); _fakeTimer = null; } + if (_overlay) { + document.removeEventListener('keydown', _overlay._escHandler); + _overlay.remove(); + _overlay = null; + } + _bar = null; + _label = null; + _currentPct = 0; +} diff --git a/paintplus/frontend/src/js/libs/tiff-writer.js b/paintplus/frontend/src/js/libs/tiff-writer.js new file mode 100644 index 0000000..be28162 --- /dev/null +++ b/paintplus/frontend/src/js/libs/tiff-writer.js @@ -0,0 +1,195 @@ +/** + * TIFF writer with support for: + * - Single-page RGBA (32-bit, interleaved, with alpha) + * - Single-page CMYK (8-bit per channel, no alpha — print-ready) + * - Multipage variants of both (one IFD per canvas layer) + * + * TIFF spec references: TIFF 6.0, ISO 12234-2 (CMYK) + * PhotometricInterpretation 2 = RGB, 5 = CMYK + */ + +// --- RGB → CMYK conversion ------------------------------------------- + +function rgbToCmyk(r, g, b) { + var rn = r / 255, gn = g / 255, bn = b / 255; + var k = 1 - Math.max(rn, gn, bn); + if (k >= 1) return [0, 0, 0, 255]; + var d = 1 - k; + return [ + Math.round((d - rn) / d * 255), + Math.round((d - gn) / d * 255), + Math.round((d - bn) / d * 255), + Math.round(k * 255), + ]; +} + +// --- Low-level TIFF binary builder ------------------------------------ + +/** + * Build a multipage TIFF buffer from an array of canvases. + * + * @param {HTMLCanvasElement[]} canvases + * @param {'rgba'|'cmyk'} colorMode + * @param {object} [opts] + * @param {boolean} [opts.littleEndian=false] + * @param {number} [opts.dpi=300] + * @returns {ArrayBuffer} + */ +function buildTIFF(canvases, colorMode, opts) { + opts = opts || {}; + var lsb = !!opts.littleEndian; + var dpi = +(opts.dpi || 300) | 0; + + var isCMYK = colorMode === 'cmyk'; + + // IFD field counts differ: RGBA has ExtraSamples tag, CMYK does not. + var ENTRY_COUNT = isCMYK ? 14 : 15; + var IFD_SIZE = 2 + ENTRY_COUNT * 12 + 4; // count + entries + nextIFD ptr + var FIELDS_SIZE = 64; // BPS(8)+XRes(8)+YRes(8)+sw(20)+dt(20) + var PAGE_OH = IFD_SIZE + FIELDS_SIZE; + + // Compute page start offsets inside the final buffer. + var offsets = []; + var total = 8; // TIFF header + for (var i = 0; i < canvases.length; i++) { + offsets.push(total); + total += PAGE_OH + canvases[i].width * canvases[i].height * 4; + } + + var buf = new ArrayBuffer(total); + var view = new DataView(buf); + var u8 = new Uint8Array(buf); + var pos = 0; + + function s16(v) { view.setUint16(pos, v, lsb); pos += 2; } + function s32(v) { view.setUint32(pos, v, lsb); pos += 4; } + function entry(tag, type, count, value) { + s16(tag); s16(type); s32(count); + // SHORT with count==1 gets packed into the value field with padding. + if (type === 3 && count === 1) { s16(value); s16(0); } + else { s32(value); } + } + + // Date helpers + var d = new Date(); + var p2 = function(n) { return n < 10 ? '0' + n : '' + n; }; + var dtStr = d.getFullYear() + ':' + p2(d.getMonth() + 1) + ':' + p2(d.getDate()) + + ' ' + p2(d.getHours()) + ':' + p2(d.getMinutes()) + ':' + p2(d.getSeconds()); + var swStr = 'tiff-writer 1.0\0\0\0\0\0'; // 20 chars (null-padded) + + // ---- TIFF header ---- + s16(lsb ? 0x4949 : 0x4d4d); + s16(42); + s32(8); // offset to first IFD + + // ---- Per-page IFDs + image data ---- + for (var p = 0; p < canvases.length; p++) { + var cv = canvases[p]; + var W = cv.width, H = cv.height; + var pageBase = offsets[p]; + var fBase = pageBase + IFD_SIZE; // start of fields section + var imgBase = fBase + FIELDS_SIZE; // start of image data + var nextIFD = p + 1 < canvases.length ? offsets[p + 1] : 0; + + // IFD entry count + s16(ENTRY_COUNT); + + entry(0x00fe, 4, 1, 0); // NewSubfileType + entry(0x0100, 4, 1, W); // ImageWidth + entry(0x0101, 4, 1, H); // ImageLength + entry(0x0102, 3, 4, fBase); // BitsPerSample (offset → 4 shorts) + entry(0x0103, 3, 1, 1); // Compression: none + entry(0x0106, 3, 1, isCMYK ? 5 : 2); // PhotometricInterp: 5=CMYK, 2=RGB + entry(0x0111, 4, 1, imgBase); // StripOffsets + entry(0x0115, 3, 1, 4); // SamplesPerPixel: 4 + entry(0x0117, 4, 1, W * H * 4); // StripByteCounts + entry(0x011a, 5, 1, fBase + 8); // XResolution + entry(0x011b, 5, 1, fBase + 16); // YResolution + entry(0x0128, 3, 1, 2); // ResolutionUnit: inch + entry(0x0131, 2, 20, fBase + 24); // Software (20 bytes) + entry(0x0132, 2, 20, fBase + 44); // DateTime (20 bytes) + if (!isCMYK) { + entry(0x0152, 3, 1, 2); // ExtraSamples: assoc. alpha (RGBA only) + } + + s32(nextIFD); + + // ---- Fields section (64 bytes) ---- + // BitsPerSample: 8,8,8,8 as four SHORTs (8 bytes) + s16(8); s16(8); s16(8); s16(8); + // XResolution RATIONAL (8 bytes) + s32(dpi); s32(1); + // YResolution RATIONAL (8 bytes) + s32(dpi); s32(1); + // Software string (20 bytes, null-padded) + for (var i = 0; i < 20; i++) + view.setUint8(pos++, swStr.charCodeAt(i) & 0xff); + // DateTime string (20 bytes, null-padded) + for (var i = 0; i < 20; i++) + view.setUint8(pos++, i < dtStr.length ? dtStr.charCodeAt(i) & 0xff : 0); + + // ---- Image data ---- + var idata = cv.getContext('2d').getImageData(0, 0, W, H).data; + if (isCMYK) { + // Convert RGBA → CMYK and write 4 bytes per pixel (alpha discarded) + for (var px = 0, len = idata.length; px < len; px += 4) { + var cmyk = rgbToCmyk(idata[px], idata[px + 1], idata[px + 2]); + u8[pos++] = cmyk[0]; + u8[pos++] = cmyk[1]; + u8[pos++] = cmyk[2]; + u8[pos++] = cmyk[3]; + } + } else { + // Write RGBA directly + u8.set(idata, pos); + pos += idata.length; + } + } + + return buf; +} + +// --- Public API ------------------------------------------------------- + +var TiffWriter = { + + /** Single-page 32-bit RGBA TIFF */ + toRGBA: function(canvas, callback, opts) { + setTimeout(function() { + callback(buildTIFF([canvas], 'rgba', opts)); + }, 9); + }, + + /** Single-page CMYK TIFF (print-ready, no alpha) */ + toCMYK: function(canvas, callback, opts) { + setTimeout(function() { + callback(buildTIFF([canvas], 'cmyk', opts)); + }, 9); + }, + + /** Multipage RGBA TIFF — one IFD per canvas in the array */ + toMultipageRGBA: function(canvases, callback, opts) { + setTimeout(function() { + callback(buildTIFF(canvases, 'rgba', opts)); + }, 9); + }, + + /** Multipage CMYK TIFF — one IFD per canvas in the array */ + toMultipageCMYK: function(canvases, callback, opts) { + setTimeout(function() { + callback(buildTIFF(canvases, 'cmyk', opts)); + }, 9); + }, + + /** Convenience: returns a Blob instead of ArrayBuffer */ + toBlob: function(canvases, colorMode, callback, opts) { + if (!Array.isArray(canvases)) canvases = [canvases]; + setTimeout(function() { + var buf = buildTIFF(canvases, colorMode, opts); + callback(new Blob([buf], { type: 'image/tiff' })); + }, 9); + }, +}; + +export default TiffWriter; +export { rgbToCmyk }; diff --git a/paintplus/frontend/src/js/libs/vintage.js b/paintplus/frontend/src/js/libs/vintage.js new file mode 100644 index 0000000..84fa77d --- /dev/null +++ b/paintplus/frontend/src/js/libs/vintage.js @@ -0,0 +1,295 @@ +import glfx from './glfx.js'; +import ImageFilters from './imagefilters.js'; + +/** + * adds vintage effect + * + * @author ViliusL + * + * Functions: + * - adjust_color + * - lower_contrast + * - blur + * - light_leak + * - chemicals + * - exposure + * - grains + * - grains_big + * - optics + * - dusts + * + * Usage: VINTAGE.___function___(canvas,, param1, param2, ...); + * + * libs: + * - imagefilters.js, url: https://github.com/arahaya/ImageFilters.js + * - glfx.js url: http://evanw.github.com/glfx.js/ + */ +class Vintage_class { + + constructor(width, height) { + this.fx_filter = false; + this.exposure_rand = null; + this.lightLeakX = null; + this.lightLeakY = null; + + this.reset_random_values(width, height); + } + + /** + * apply all affect + * + * @param {canvas} canvas + * @param {int} level 0-100 + */ + apply_all(canvas, level) { + //adjust from scale [0-100] to our scale. + var red_offset = level * 1; //[0, 100] + var contrast = level / 2; //[0, 50] + //var blur = level / 100; //[0, 1] + var light_leak = level * 1.5; //[0, 150] + var de_saturation = level * 1; //[0, 100] + var exposure = level * 1.5; //[0, 150] + var grains = level / 2; //[0, 50] + var big_grains = level / 5; //[0, 20] + var vignette_size = level / 200; //[0, 0.5] + var vignette_amount = level / 142; //[0, 0.7] + var dust_level = level * 1; //[0, 100] + + this.adjust_color(canvas, red_offset); + this.lower_contrast(canvas, contrast); + //this.blur(canvas, blur); + this.light_leak(canvas, light_leak); + this.chemicals(canvas, de_saturation); + this.exposure(canvas, exposure); + this.grains(canvas, grains); + this.grains_big(canvas, big_grains); + this.optics(canvas, vignette_size, vignette_amount); + this.dusts(canvas, dust_level); + } + + /** + * reset random values again. + * + * @param {int} width + * @param {int} height + */ + reset_random_values(width, height) { + this.exposure_rand = this.getRandomInt(1, 10); + this.lightLeakX = this.getRandomInt(0, width); + this.lightLeakY = this.getRandomInt(0, height); + } + + //increasing red color + adjust_color(canvas, level_red) { //level = [0, 200], default 70 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + var param_green = 0; + var param_blue = 0; + var imageData = context.getImageData(0, 0, W, H); + var filtered = ImageFilters.ColorTransformFilter(imageData, 1, 1, 1, 1, level_red, param_green, param_blue, 1); + context.putImageData(filtered, 0, 0); + } + + //decreasing contrast + lower_contrast(canvas, level) { //level = [0, 50], default 15 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + + var imageData = context.getImageData(0, 0, W, H); + var filtered = ImageFilters.BrightnessContrastPhotoshop(imageData, 0, -level); + context.putImageData(filtered, 0, 0); + } + + //adding blur + blur(canvas, level) { //level = [0, 2], default 0 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + + if (level < 1) + return context; + var imageData = context.getImageData(0, 0, W, H); + var filtered = ImageFilters.GaussianBlur(imageData, level); + context.putImageData(filtered, 0, 0); + } + + //creating transparent #ffa500 radial gradients + light_leak(canvas, level) { //level = [0, 150], default 90 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + var click_x = this.lightLeakX; + var click_y = this.lightLeakY; + var distance = Math.min(W, H) * 0.6; + var radgrad = context.createRadialGradient( + click_x, click_y, distance * level / 255, + click_x, click_y, distance); + radgrad.addColorStop(0, "rgba(255, 165, 0, " + level / 255 + ")"); + radgrad.addColorStop(1, "rgba(255, 255, 255, 0)"); + + context.fillStyle = radgrad; + context.fillRect(0, 0, W, H); + } + + //de-saturate + chemicals(canvas, level) { //level = [0, 100], default 40 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + var imageData = context.getImageData(0, 0, W, H); + var filtered = ImageFilters.HSLAdjustment(imageData, 0, -level, 0); + context.putImageData(filtered, 0, 0); + } + + //creating transparent vertical black-to-white gradients + exposure(canvas, level) { //level = [0, 150], default 80 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + context.rect(0, 0, W, H); + var grd = context.createLinearGradient(0, 0, 0, H); + if (this.exposure_rand < 5) { + //dark at top + grd.addColorStop(0, "rgba(0, 0, 0, " + level / 255 + ")"); + grd.addColorStop(1, "rgba(255, 255, 255, " + level / 255 + ")"); + } + else { + //bright at top + grd.addColorStop(0, "rgba(255, 255, 255, " + level / 255 + ")"); + grd.addColorStop(1, "rgba(0, 0, 0, " + level / 255 + ")"); + } + context.fillStyle = grd; + context.fill(); + } + + //add grains, noise + grains(canvas, level) { //level = [0, 50], default 10 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + if (level == 0) + return context; + var img = context.getImageData(0, 0, W, H); + var imgData = img.data; + for (var j = 0; j < H; j++) { + for (var i = 0; i < W; i++) { + var x = (i + j * W) * 4; + if (imgData[x + 3] == 0) + continue; //transparent + //increase it's lightness + var delta = this.getRandomInt(0, level); + if (delta == 0) + continue; + + if (imgData[x] - delta < 0) + imgData[x] = -(imgData[x] - delta); + else + imgData[x] = imgData[x] - delta; + if (imgData[x + 1] - delta < 0) + imgData[x + 1] = -(imgData[x + 1] - delta); + else + imgData[x + 1] = imgData[x + 1] - delta; + if (imgData[x + 2] - delta < 0) + imgData[x + 2] = -(imgData[x + 2] - delta); + else + imgData[x + 2] = imgData[x + 2] - delta; + } + } + context.putImageData(img, 0, 0); + } + + //add big grains, noise + grains_big(canvas, level) { //level = [0, 50], default 20 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + if (level == 0) + return context; + var n = W * H / 100 * level; //density + var color = 200; + for (var i = 0; i < n; i++) { + var power = this.getRandomInt(5, 10 + level); + var size = 2; + var x = this.getRandomInt(0, W); + var y = this.getRandomInt(0, H); + context.fillStyle = "rgba(" + color + ", " + color + ", " + color + ", " + power / 255 + ")"; + context.fillRect(x, y, size, size); + } + } + + //adding vignette effect - blurred dark borders + optics(canvas, param1, param2) { //param1 [0, 0.5], param2 [0, 0.7], default 0.3, 0.5 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var texture = this.fx_filter.texture(context.getImageData(0, 0, W, H)); + this.fx_filter.draw(texture).vignette(param1, param2).update(); + context.drawImage(this.fx_filter, 0, 0); + } + + //add dust and hairs + dusts(canvas, level) { //level = [0, 100], default 70 + var context = canvas.getContext("2d"); + var W = canvas.width; + var H = canvas.height; + + var n = level / 100 * (W * H) / 1000; + //add dust + context.fillStyle = "rgba(200, 200, 200, 0.3)"; + for (var i = 0; i < n; i++) { + var x = this.getRandomInt(0, W); + var y = this.getRandomInt(0, H); + var mode = this.getRandomInt(1, 2); + if (mode == 1) { + var w = 1; + var h = this.getRandomInt(1, 3); + } + else if (mode == 2) { + var w = this.getRandomInt(1, 3); + var h = 1; + } + context.beginPath(); + context.rect(x, y, w, h); + context.fill(); + } + + //add hairs + context.strokeStyle = "rgba(200, 200, 200, 0.2)"; + for (var i = 0; i < n / 20; i++) { + var x = this.getRandomInt(0, W); + var y = this.getRandomInt(0, H); + var radius = this.getRandomInt(5, 10); + var start_nr = this.getRandomInt(0, 20) / 10; + var start_angle = Math.PI * start_nr; + var end_angle = Math.PI * (start_nr + this.getRandomInt(7, 15) / 10); + context.beginPath(); + context.arc(x, y, radius, start_angle, end_angle); + context.stroke(); + } + + return context; + } + + //random number generator + getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } +} + +export default Vintage_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/libs/zoomView.js b/paintplus/frontend/src/js/libs/zoomView.js new file mode 100644 index 0000000..10c9f4c --- /dev/null +++ b/paintplus/frontend/src/js/libs/zoomView.js @@ -0,0 +1,150 @@ +//handles zoom and pan +//https://stackoverflow.com/questions/44009094/how-to-bound-image-pan-when-zooming-html-canvas/44015705#44015705 +const zoomView = (() => { + const matrix = [1, 0, 0, 1, 0, 0]; // current view transform + const invMatrix = [1, 0, 0, 1, 0, 0]; // current inverse view transform + var m = matrix; // alias + var im = invMatrix; // alias + var scale = 1; // current scale + const bounds = { + top: 0, + left: 0, + right: 200, + bottom: 200, + }; + var useConstraint = true; // if true then limit pan and zoom to + // keep bounds within the current context + + var maxScale = 1; + const workPoint1 = {x: 0, y: 0}; + const workPoint2 = {x: 0, y: 0}; + const wp1 = workPoint1; // alias + const wp2 = workPoint2; // alias + var ctx; + const pos = {// current position of origin + x: 0, + y: 0, + }; + var dirty = true; + const API = { + canvasDefault() { + ctx.setTransform(1, 0, 0, 1, 0, 0); + }, + apply() { + if (dirty) { + this.update(); + } + ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]); + }, + getPosition() { + return { x: pos.x, y: pos.y }; + }, + getContext() { + return ctx; + }, + getBounds() { + return bounds; + }, + getScale() { + return scale; + }, + getMaxScale() { + return maxScale; + }, + matrix, // expose the matrix + invMatrix, // expose the inverse matrix + update() { // call to update transforms + dirty = false; + m[3] = m[0] = scale; + m[1] = m[2] = 0; + m[4] = pos.x; + m[5] = pos.y; + if (useConstraint) { + this.constrain(); + } + this.invScale = 1 / scale; + // calculate the inverse transformation + var cross = m[0] * m[3] - m[1] * m[2]; + im[0] = m[3] / cross; + im[1] = -m[1] / cross; + im[2] = -m[2] / cross; + im[3] = m[0] / cross; + }, + constrain() { + maxScale = Math.min( + ctx.canvas.width / (bounds.right - bounds.left), + ctx.canvas.height / (bounds.bottom - bounds.top) + ); + if (scale < maxScale) { + m[0] = m[3] = scale = maxScale; + } + wp1.x = bounds.left; + wp1.y = bounds.top; + this.toScreen(wp1, wp2); + if (wp2.x > 0) { + m[4] = pos.x -= wp2.x; + } + if (wp2.y > 0) { + m[5] = pos.y -= wp2.y; + } + wp1.x = bounds.right; + wp1.y = bounds.bottom; + this.toScreen(wp1, wp2); + if (wp2.x < ctx.canvas.width) { + m[4] = (pos.x -= wp2.x - ctx.canvas.width); + } + if (wp2.y < ctx.canvas.height) { + m[5] = (pos.y -= wp2.y - ctx.canvas.height); + } + }, + toWorld(from_x, from_y) { // convert screen to world coords + var xx, yy; + var pointW = {}; + if (dirty) { + this.update(); + } + xx = from_x - m[4]; + yy = from_y - m[5]; + pointW.x = xx * im[0] + yy * im[2]; + pointW.y = xx * im[1] + yy * im[3]; + return pointW; + }, + toScreen(from, point = {}){ // convert world coords to screen coords + if (dirty) { + this.update(); + } + point.x = from.x * m[0] + from.y * m[2] + m[4]; + point.y = from.x * m[1] + from.y * m[3] + m[5]; + return point; + }, + scaleAt(x_from, y_from, amount) { // at in screen coords + if (dirty) { + this.update(); + } + scale *= amount; + pos.x = x_from - (x_from - pos.x) * amount; + pos.y = y_from - (y_from - pos.y) * amount; + dirty = true; + }, + move(move_x, move_y) { // move is in screen coords + pos.x += move_x; + pos.y += move_y; + dirty = true; + }, + setContext(context) { + ctx = context; + dirty = true; + }, + setBounds(top, left, right, bottom) { + bounds.top = top; + bounds.left = left; + bounds.right = right; + bounds.bottom = bottom; + useConstraint = true; + dirty = true; + } + }; + return API; +})(); + +export default zoomView; \ No newline at end of file diff --git a/paintplus/frontend/src/js/main.js b/paintplus/frontend/src/js/main.js new file mode 100644 index 0000000..02db30d --- /dev/null +++ b/paintplus/frontend/src/js/main.js @@ -0,0 +1,127 @@ +/** + * miniPaint - https://github.com/viliusle/miniPaint + * author: Vilius L. + */ + +//css +import './../css/reset.css'; +import './../css/utility.css'; +import './../css/component.css'; +import './../css/layout.css'; +import './../css/menu.css'; +import './../css/print.css'; +import './../../node_modules/alertifyjs/build/css/alertify.min.css'; +//js +import app from './app.js'; +import config from './config.js'; +import './core/components/index.js'; +import Base_gui_class from './core/base-gui.js'; +import Base_layers_class from './core/base-layers.js'; +import Base_tools_class from './core/base-tools.js'; +import Base_state_class from './core/base-state.js'; +import Base_search_class from './core/base-search.js'; +import File_open_class from './modules/file/open.js'; +import File_save_class from './modules/file/save.js'; +import * as Actions from './actions/index.js'; +import { mountProviderBadge } from './core/components/provider-badge.js'; + +window.addEventListener('load', function (e) { + // Initiate app + var Layers = new Base_layers_class(); + var Base_tools = new Base_tools_class(true); + var GUI = new Base_gui_class(); + var Base_state = new Base_state_class(); + var File_open = new File_open_class(); + var File_save = new File_save_class(); + var Base_search = new Base_search_class(); + + // Register singletons in app module + app.Actions = Actions; + app.Config = config; + app.FileOpen = File_open; + app.FileSave = File_save; + app.GUI = GUI; + app.Layers = Layers; + app.State = Base_state; + app.Tools = Base_tools; + + // Register as global for quick or external access + window.Layers = Layers; + window.AppConfig = config; + window.State = Base_state; + window.FileOpen = File_open; + window.FileSave = File_save; + + // Render all + GUI.init(); + Layers.init(); + + // Mount provider badge in the tools panel footer + mountProviderBadge(document.getElementById('tools_container') || document.body); + + // Collapse right-panel Colors section by default (compact color swatch on left toolbar instead) + _collapseColorsPanel(); + // Mount compact foreground/background color swatches at bottom of left toolbar + _mountToolbarColorSwatch(); +}, false); + +function _collapseColorsPanel() { + var toggle = document.querySelector('[data-target="toggle_colors"]'); + var panel = document.getElementById('toggle_colors'); + if (toggle && panel) { + // Only collapse if user hasn't explicitly expanded it (no saved cookie) + var Helper = { getCookie: (k) => { var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]+)'); return m ? m.pop() : null; } }; + if (Helper.getCookie('toggle_colors') !== '1') { + panel.classList.add('hidden'); + toggle.classList.add('toggled'); + } + } +} + +function _mountToolbarColorSwatch() { + var toolbar = document.getElementById('tools_container'); + if (!toolbar) return; + + // Spacer to push swatch to bottom + var spacer = document.createElement('div'); + spacer.style.cssText = 'flex:1;min-height:8px;width:100%;'; + toolbar.appendChild(spacer); + + // Foreground / background color squares (click to open full color picker) + var wrap = document.createElement('div'); + wrap.id = 'toolbar_color_swatch'; + wrap.title = 'Foreground / Background color — click to open color picker'; + wrap.style.cssText = 'position:relative;width:30px;height:30px;margin:4px 0 4px 5px;cursor:pointer;flex-shrink:0;'; + wrap.innerHTML = ` +
    +
    `; + toolbar.appendChild(wrap); + + // Keep swatch in sync with config.COLOR + function _syncSwatch() { + var fg = document.getElementById('tc_fg'); + var bg = document.getElementById('tc_bg'); + if (fg) fg.style.background = window.config && config.COLOR ? config.COLOR : '#008000'; + } + setInterval(_syncSwatch, 250); + + // Click → open the right-side color panel + wrap.addEventListener('click', function () { + var panel = document.getElementById('toggle_colors'); + var toggle = document.querySelector('[data-target="toggle_colors"]'); + if (!panel) return; + var hidden = panel.classList.contains('hidden'); + if (hidden) { + panel.classList.remove('hidden'); + if (toggle) toggle.classList.remove('toggled'); + // Scroll right panel to top so color picker is visible + var sidebar = document.querySelector('.sidebar_right'); + if (sidebar) sidebar.scrollTop = 0; + } else { + panel.classList.add('hidden'); + if (toggle) toggle.classList.add('toggled'); + } + }); +} diff --git a/paintplus/frontend/src/js/modules/edit/copy.js b/paintplus/frontend/src/js/modules/edit/copy.js new file mode 100644 index 0000000..1e5c55d --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/copy.js @@ -0,0 +1,82 @@ +import config from "../../config"; +import Base_layers_class from './../../core/base-layers.js'; +import File_save_class from './../file/save.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Copy_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.File_save = new File_save_class(); + + //events + document.addEventListener('keydown', (event) => { + var code = event.key.toLowerCase(); + var ctrlDown = event.ctrlKey || event.metaKey; + if (this.Helper.is_input(event.target)) + return; + + if (code == "c" && ctrlDown == true) { + //copy to clipboard + this.copy_to_clipboard(); + } + }, false); + } + + async copy_to_clipboard(){ + var _this = this; + + const canWriteToClipboard = await this.askWritePermission(); + if (canWriteToClipboard) { + + //get data - current layer + var canvas = this.Base_layers.convert_layer_to_canvas(); + var ctx = canvas.getContext("2d"); + + if (config.TRANSPARENCY == false) { + //add white background + ctx.globalCompositeOperation = 'destination-over'; + this.File_save.fillCanvasBackground(ctx, '#ffffff'); + ctx.globalCompositeOperation = 'source-over'; + } + + //save using lib + canvas.toBlob(function (blob) { + _this.setToClipboard(blob); + }); + } + else{ + alertify.error('Missing permissions to write to Clipboard.cc'); + } + } + + async setToClipboard(blob) { + const data = [new ClipboardItem({ [blob.type]: blob })]; + await navigator.clipboard.write(data); + } + + async askWritePermission() { + try { + // The clipboard-write permission is granted automatically to pages + // when they are the active tab. So it's not required, but it's more safe. + const { state } = await navigator.permissions.query({ name: 'clipboard-write' }) + return state === 'granted'; + } + catch (error) { + // Browser compatibility / Security error (ONLY HTTPS) ... + return false; + } + } +} + +export default Copy_class; diff --git a/paintplus/frontend/src/js/modules/edit/history_panel.js b/paintplus/frontend/src/js/modules/edit/history_panel.js new file mode 100644 index 0000000..310d829 --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/history_panel.js @@ -0,0 +1,145 @@ +/** + * History Panel — visual undo history timeline. + * Shows the last N actions as a clickable list. Click any item to undo/redo to that point. + * Docks as a floating panel on the right side of the screen. + * + * Menu target: edit/history_panel.toggle + */ + +import app from './../../app.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Edit_history_panel_class { + constructor() { + if (instance) return instance; + instance = this; + this._panel = null; + this._interval = null; + } + + toggle() { + if (this._panel) { + this._stop(); + } else { + this._start(); + } + } + + _start() { + this._buildPanel(); + this._render(); + // Refresh whenever the history changes (poll lightly) + this._interval = setInterval(() => this._render(), 800); + } + + _stop() { + if (this._interval) { clearInterval(this._interval); this._interval = null; } + if (this._panel) { this._panel.remove(); this._panel = null; } + } + + _buildPanel() { + const panel = document.createElement('div'); + panel.id = 'history_panel'; + Object.assign(panel.style, { + position: 'fixed', + top: '60px', + right: '0', + width: '200px', + maxHeight: 'calc(100vh - 80px)', + overflowY: 'auto', + background: '#1a1a1a', + borderLeft: '1px solid #333', + borderBottom: '1px solid #333', + borderRadius: '0 0 0 10px', + zIndex: '8888', + fontFamily: 'sans-serif', + fontSize: '12px', + color: '#ccc', + boxShadow: '-4px 4px 16px rgba(0,0,0,0.4)', + userSelect: 'none', + }); + panel.innerHTML = ` +
    + History + × +
    +
    `; + document.body.appendChild(panel); + this._panel = panel; + panel.querySelector('#hist-close').addEventListener('click', () => this._stop()); + } + + _render() { + if (!this._panel) return; + const list = this._panel.querySelector('#hist-list'); + if (!list) return; + + const history = app.State.action_history || []; + const idx = app.State.action_history_index ?? history.length; + + if (history.length === 0) { + list.innerHTML = `
    No actions yet.
    `; + return; + } + + // Build rows newest-first + const rows = []; + // "Current state" row at top + const atTop = idx >= history.length; + rows.push(`
    + ${atTop ? '▶' : '○'}Current state +
    `); + + for (let i = history.length - 1; i >= 0; i--) { + const action = history[i]; + const isCurrent = (i === idx - 1); + const isFuture = (i >= idx); + const label = action.action_description || action.action_id || `Step ${i + 1}`; + rows.push(`
    + ${isCurrent ? '▶' : isFuture ? '○' : '·'}${_escHtml(label)} +
    `); + } + list.innerHTML = rows.join(''); + + // Wire clicks + list.querySelectorAll('[data-idx]').forEach(el => { + el.addEventListener('click', () => { + const target = parseInt(el.dataset.idx, 10); + this._jumpTo(target); + }); + }); + } + + _jumpTo(targetIdx) { + const history = app.State.action_history || []; + const current = app.State.action_history_index ?? history.length; + + if (targetIdx === current) return; + + const steps = targetIdx - current; + if (steps > 0) { + for (let i = 0; i < steps; i++) app.State.redo_action(); + } else { + for (let i = 0; i < Math.abs(steps); i++) app.State.undo_action(); + } + this._render(); + } +} + +function _escHtml(s) { + return String(s).replace(/&/g,'&').replace(//g,'>'); +} + +export default Edit_history_panel_class; diff --git a/paintplus/frontend/src/js/modules/edit/paste.js b/paintplus/frontend/src/js/modules/edit/paste.js new file mode 100644 index 0000000..acb8ee8 --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/paste.js @@ -0,0 +1,10 @@ +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Edit_paste_class { + + paste() { + alertify.error('Use Ctrl+V keyboard shortcut to paste from Clipboard.'); + } +} + +export default Edit_paste_class; diff --git a/paintplus/frontend/src/js/modules/edit/redo.js b/paintplus/frontend/src/js/modules/edit/redo.js new file mode 100644 index 0000000..0312b6b --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/redo.js @@ -0,0 +1,14 @@ +import Base_state_class from './../../core/base-state.js'; + +class Edit_redo_class { + + constructor() { + this.Base_state = new Base_state_class(); + } + + redo() { + this.Base_state.redo(); + } +} + +export default Edit_redo_class; diff --git a/paintplus/frontend/src/js/modules/edit/selection.js b/paintplus/frontend/src/js/modules/edit/selection.js new file mode 100644 index 0000000..bd772ba --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/selection.js @@ -0,0 +1,26 @@ +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Selection_class from './../../tools/selection.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Edit_selection_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + this.Selection = new Selection_class(this.Base_layers.ctx); + } + + select_all() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + this.Selection.select_all(); + } + + delete() { + this.Selection.delete_selection(); + } +} + +export default Edit_selection_class; diff --git a/paintplus/frontend/src/js/modules/edit/undo.js b/paintplus/frontend/src/js/modules/edit/undo.js new file mode 100644 index 0000000..bae4a85 --- /dev/null +++ b/paintplus/frontend/src/js/modules/edit/undo.js @@ -0,0 +1,31 @@ +import Base_state_class from './../../core/base-state.js'; + +var instance = null; + +class Edit_undo_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_state = new Base_state_class(); + this.events(); + } + + events(){ + var _this = this; + + document.querySelector('#undo_button').addEventListener('click', function (event) { + _this.Base_state.undo(); + }); + } + + undo() { + this.Base_state.undo(); + } +} + +export default Edit_undo_class; diff --git a/paintplus/frontend/src/js/modules/effects/abstract/css.js b/paintplus/frontend/src/js/modules/effects/abstract/css.js new file mode 100644 index 0000000..88c9f70 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/abstract/css.js @@ -0,0 +1,71 @@ +import app from './../../../app.js'; +import config from './../../../config.js'; +import Dialog_class from './../../../libs/popup.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import Helper_class from './../../../libs/helpers.js'; + +class Effects_common_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.params = null; + } + + show_dialog(type, params, filter_id) { + var _this = this; + var title = this.Helper.ucfirst(type); + title = title.replace(/-/g, ' '); + + var preview_padding = 0; + if(typeof this.preview_padding != "undefined"){ + preview_padding = this.preview_padding; + } + + var settings = { + title: title, + preview: true, + preview_padding: preview_padding, + effects: true, + params: params, + on_change: function (params, canvas_preview, w, h) { + _this.params = params; + canvas_preview.filter = _this.preview(params, type); + canvas_preview.drawImage(this.layer_active_small, + preview_padding, preview_padding, + _this.POP.width_mini - preview_padding * 2, _this.POP.height_mini - preview_padding * 2 + ); + }, + on_finish: function (params) { + _this.params = params; + _this.save(params, type, filter_id); + }, + }; + this.Base_layers.disable_filter(filter_id); + this.POP.show(settings); + this.Base_layers.disable_filter(null); + } + + save(params, type, filter_id) { + return app.State.do_action( + new app.Actions.Add_layer_filter_action(null, type, params, filter_id) + ); + } + + preview(params, type) { + if(type == 'shadow'){ + type = 'drop-shadow'; + } + + var value = this.convert_value(params.value, params, 'preview'); + return type + "(" + value + ")"; + } + + convert_value(value, params) { + return value; + } + +} + +export default Effects_common_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/black_and_white.js b/paintplus/frontend/src/js/modules/effects/black_and_white.js new file mode 100644 index 0000000..6755428 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/black_and_white.js @@ -0,0 +1,219 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_backAndWhite_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + } + + black_and_white() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //create tmp canvas + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //calc default level + var default_level = this.thresholding(ctx, canvas.width, canvas.height, true); + + var settings = { + title: 'Black and White', + preview: true, + effects: true, + params: [ + {name: "level", title: "Level:", value: default_level, range: [0, 255]}, + {name: "dithering", title: "Dithering:", value: false}, + ], + on_change: function (params, canvas_preview, w, h) { + //check params + var level = document.getElementById("pop_data_level"); + if (params.dithering == false) + level.disabled = false; + else + level.disabled = true; + + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var W = data.width; + var H = data.height; + + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = W; + canvas.height = H; + + var imgData = data.data; + var grey, c, quant_error, m; + if (params.dithering !== true) { + //no differing + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + if (grey <= params.level) + c = 0; + else + c = 255; + imgData[i] = c; + imgData[i + 1] = c; + imgData[i + 2] = c; + } + } + else { + //Floyd–Steinberg dithering + var img2 = canvas.getContext("2d").getImageData(0, 0, W, H); + var imgData2 = img2.data; + for (var j = 0; j < H; j++) { + for (var i = 0; i < W; i++) { + var k = ((j * (W * 4)) + (i * 4)); + if (imgData[k + 3] == 0) + continue; //transparent + + grey = Math.round(0.2126 * imgData[k] + 0.7152 * imgData[k + 1] + 0.0722 * imgData[k + 2]); + grey = grey + imgData2[k]; //add data shft from previous iterations + c = Math.floor(grey / 256); + if (c == 1) + c = 255; + imgData[k] = c; + imgData[k + 1] = c; + imgData[k + 2] = c; + quant_error = grey - c; + if (i + 1 < W) { + m = k + 4; + imgData2[m] += Math.round(quant_error * 7 / 16); + } + if (i - 1 > 0 && j + 1 < H) { + m = k - 4 + W * 4; + imgData2[m] += Math.round(quant_error * 3 / 16); + } + if (j + 1 < H) { + m = k + W * 4; + imgData2[m] += Math.round(quant_error * 5 / 16); + } + if (i + 1 < W && j + 1 < H) { + m = k + 4 + W * 4; + imgData2[m] += Math.round(quant_error * 1 / 16); + } + } + } + } + return data; + } + + thresholding(ctx, W, H, only_level) { + var img = ctx.getImageData(0, 0, W, H); + var imgData = img.data; + var hist_data = []; + var grey; + for (var i = 0; i <= 255; i++) + hist_data[i] = 0; + for (var i = 0; i < imgData.length; i += 4) { + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + hist_data[grey]++; + } + var level = this.otsu(hist_data, W * H); + if (only_level === true) + return level; + var c; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + if (grey < level) + c = 0; + else + c = 255; + imgData[i] = c; + imgData[i + 1] = c; + imgData[i + 2] = c; + } + ctx.putImageData(img, 0, 0); + } + + //http://en.wikipedia.org/wiki/Otsu%27s_Method + otsu(histogram, total) { + var sum = 0; + for (var i = 1; i < 256; ++i) + sum += i * histogram[i]; + var mB, mF, between; + var sumB = 0; + var wB = 0; + var wF = 0; + var max = 0; + var threshold = 0; + for (var i = 0; i < 256; ++i) { + wB += histogram[i]; + if (wB == 0) + continue; + wF = total - wB; + if (wF == 0) + break; + sumB += i * histogram[i]; + mB = sumB / wB; + mF = (sum - sumB) / wF; + between = wB * wF * Math.pow(mB - mF, 2); + if (between > max) { + max = between; + threshold = i; + } + } + return threshold; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var default_level = this.thresholding(ctx, canvas_thumb.width, canvas_thumb.height, true); + var params = { + level: default_level, + dithering: false, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_backAndWhite_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/blueprint.js b/paintplus/frontend/src/js/modules/effects/blueprint.js new file mode 100644 index 0000000..7a60163 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/blueprint.js @@ -0,0 +1,157 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_blueprint_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.ImageFilters = ImageFilters; + this.fx_filter = false; + } + + blueprint() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + var ctx = canvas.getContext("2d"); + + //create blue layer + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.fillStyle = '#0e58a3'; + ctx2.fillRect(0, 0, width, height); + + //apply edges + var img = ctx.getImageData(0, 0, width, height); + var img = this.ImageFilters.Edge(img); + ctx.putImageData(img, 0, 0); + + //denoise + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).denoise(20).update(); //effect + canvas = this.fx_filter; + + //Brightness + var img = ctx.getImageData(0, 0, width, height); + var img = this.ImageFilters.BrightnessContrastPhotoshop(img, 80, 0); + ctx.putImageData(img, 0, 0); + + //merge + ctx2.globalCompositeOperation = "screen"; + ctx2.filter = 'grayscale(1)'; + ctx2.drawImage(canvas, 0, 0); + ctx2.globalCompositeOperation = "source-over"; + ctx2.filter = 'none'; + + //draw lines + this.draw_grid(ctx2, 20); + + return canvas2; + } + + /** + * draw grid + * + * @param {CanvasContext} ctx + * @param {Int} size + */ + draw_grid(ctx, size) { + if (this.grid == false) + return; + + var width = config.WIDTH; + var height = config.HEIGHT; + var color_main = 'rgba(255, 255, 255, 0.5)'; + var color_small = 'rgba(255, 255, 255, 0.1)'; + + //size + if (size != undefined && size != undefined) + this.grid_size = [size, size]; + else { + size = this.grid_size[0]; + size = this.grid_size[1]; + } + size = parseInt(size); + size = parseInt(size); + ctx.lineWidth = 1; + ctx.beginPath(); + if (size < 2) + size = 2; + if (size < 2) + size = 2; + for (var i = size; i < width; i = i + size) { + if (size == 0) + break; + if (i % (size * 5) == 0) { + //main lines + ctx.strokeStyle = color_main; + } + else { + //small lines + ctx.strokeStyle = color_small; + } + ctx.beginPath(); + ctx.moveTo(0.5 + i, 0); + ctx.lineTo(0.5 + i, height); + ctx.stroke(); + } + for (var i = size; i < height; i = i + size) { + if (size == 0) + break; + if (i % (size * 5) == 0) { + //main lines + ctx.strokeStyle = color_main; + } + else { + //small lines + ctx.strokeStyle = color_small; + } + ctx.beginPath(); + ctx.moveTo(0, 0.5 + i); + ctx.lineTo(width, 0.5 + i); + ctx.stroke(); + } + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var data = this.change(canvas, canvas_thumb.width, canvas_thumb.height); + ctx.drawImage(data, 0, 0); + } +} + +export default Effects_blueprint_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/borders.js b/paintplus/frontend/src/js/modules/effects/borders.js new file mode 100644 index 0000000..0ba3ddb --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/borders.js @@ -0,0 +1,107 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Effects_browser_class from "./browser"; + +class Effects_borders_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Effects_browser = new Effects_browser_class(); + } + + borders(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var _this = this; + var filter = this.Base_layers.find_filter_by_id(filter_id, 'borders'); + + var settings = { + title: 'Borders', + params: [ + {name: "color", title: "Color:", value: filter.color ??= config.COLOR, type: 'color'}, + {name: "size", title: "Size:", value: filter.size ??= 10}, + ], + on_finish: function (params) { + var target = Math.min(config.WIDTH, config.HEIGHT); + _this.add_borders(params, filter_id); + }, + }; + var rotate = config.layer.rotate; + config.layer.rotate = 0; + this.Base_layers.disable_filter(filter_id); + this.POP.show(settings); + config.layer.rotate = rotate; + this.Base_layers.disable_filter(null); + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + ctx.drawImage(canvas_thumb, + 5, 5, + this.Effects_browser.preview_width - 10, this.Effects_browser.preview_height - 10); + + //add borders + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 10; + ctx.beginPath(); + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.stroke(); + } + + render_pre(ctx, data) { + + } + + render_post(ctx, data, layer){ + var size = Math.max(0, data.params.size); + + var x = layer.x; + var y = layer.y; + var width = parseInt(layer.width); + var height = parseInt(layer.height); + + //legacy check + if(x == null) x = 0; + if(y == null) y = 0; + if(!width) width = config.WIDTH; + if(!height) height = config.HEIGHT; + + ctx.save(); + + //set styles + ctx.strokeStyle = data.params.color; + ctx.lineWidth = size; + + //draw with rotation support + ctx.translate(layer.x + width / 2, layer.y + height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + var x_new = -width / 2; + var y_new = -height / 2; + + ctx.beginPath(); + ctx.rect(x_new - size * 0.5, y_new - size * 0.5, width + size, height + size); + ctx.stroke(); + + ctx.restore(); + } + + add_borders(params, filter_id) { + //apply effect + return app.State.do_action( + new app.Actions.Add_layer_filter_action(config.layer.id, 'borders', params, filter_id) + ); + } + +} + +export default Effects_borders_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/box_blur.js b/paintplus/frontend/src/js/modules/effects/box_blur.js new file mode 100644 index 0000000..e3dc1f2 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/box_blur.js @@ -0,0 +1,88 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_boxBlur_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + box_blur() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Box blur', + preview: true, + effects: true, + params: [ + {name: "param1", title: "H Radius:", value: 3, range: [1, 20]}, + {name: "param2", title: "V Radius:", value: 3, range: [1, 20]}, + {name: "param3", title: "Quality:", value: 3, range: [1, 20]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var param1 = params.param1; + var param2 = params.param2; + var param3 = params.param3; + + var filtered = ImageFilters.BoxBlur(data, param1, param2, param3); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + param1: 20, + param2: 1, + param3: 1, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_boxBlur_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/browser.js b/paintplus/frontend/src/js/modules/effects/browser.js new file mode 100644 index 0000000..ed57f03 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/browser.js @@ -0,0 +1,140 @@ +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_browser_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.POP = new Dialog_class(); + this.preview_width = 150; + this.preview_height = 120; + } + + async browser() { + var _this = this; + var html = ''; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var data = this.get_effects_list(); + + for (var i in data) { + var title = data[i].title; + + html += '
    '; + html += ' '; + html += '
    ' + title + '
    '; + html += '
    '; + } + for (var i = 0; i < 4; i++) { + html += '
    '; + } + + var settings = { + title: 'Effects browser', + className: 'wide', + on_load: function (params, popup) { + var node = document.createElement("div"); + node.classList.add('flex-container'); + node.innerHTML = html; + popup.el.querySelector('.dialog_content').appendChild(node); + //events + var targets = popup.el.querySelectorAll('.item canvas'); + for (var i = 0; i < targets.length; i++) { + targets[i].addEventListener('click', function (event) { + //we have click + var key = this.dataset.key; + for (var i in data) { + if(data[i].key == key){ + var function_name = _this.get_function_from_path(key); + _this.POP.hide(); + data[i].object[function_name](); + } + } + }); + } + }, + }; + this.POP.show(settings); + + //sleep, lets wait till DOM is finished + await new Promise(r => setTimeout(r, 10)); + + //generate thumb + var active_image = this.Base_layers.convert_layer_to_canvas(); + + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = this.preview_width; + canvas.height = this.preview_height; + + ctx.scale(this.preview_width / active_image.width, this.preview_height / active_image.height); + ctx.drawImage(active_image, 0, 0); + ctx.scale(1, 1); + + //draw demo thumbs + for (var i in data) { + var title = data[i].title; + var function_name = 'demo'; + if(typeof data[i].object[function_name] == "undefined") + continue; + data[i].object[function_name]('c_'+data[i].key, canvas); + } + } + + get_effects_list() { + var list = []; + + for (var i in this.Base_gui.modules) { + if (i.indexOf("effects") == -1 || i.indexOf("abstract") > -1 || i.indexOf("browser") > -1) + continue; + + list.push({ + title: this.get_filter_title(i), + key: i, + object: this.Base_gui.modules[i], + }); + } + + list.sort(function(a, b) { + var nameA = a.title.toUpperCase(); + var nameB = b.title.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }); + + return list; + } + + get_filter_title(key) { + var parts = key.split("/"); + var title = parts[parts.length - 1]; + + //exceptions + if (title == 'negative') + title = 'invert'; + + title = title.replace(/_/g, ' '); + title = title.charAt(0).toUpperCase() + title.slice(1); //make first letter uppercase + + return title; + } + + get_function_from_path(path){ + var parts = path.split("/"); + var result = parts[parts.length - 1]; + result = result.replace(/-/, '_'); + + return result; + } +} + +export default Effects_browser_class; diff --git a/paintplus/frontend/src/js/modules/effects/common/blur.js b/paintplus/frontend/src/js/modules/effects/common/blur.js new file mode 100644 index 0000000..254efc0 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/blur.js @@ -0,0 +1,68 @@ +import config from '../../../config.js'; +import Effects_common_class from '../abstract/css.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_blur_class extends Effects_common_class { + + constructor() { + super(); + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + blur(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'blur'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 5, range: [0, 50]}, + ]; + this.show_dialog('blur', params, filter_id); + } + + convert_value(value, params, type) { + + //adapt size to real canvas dimensions + if (type == 'preview') { + var diff = (this.POP.width_mini / this.POP.height_mini) / (config.WIDTH / config.HEIGHT); + + value = value * diff; + } + + return value + 'px'; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(5, null, 'preview'); + ctx.filter = "blur("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'blur(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_blur_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/brightness.js b/paintplus/frontend/src/js/modules/effects/common/brightness.js new file mode 100644 index 0000000..2fc9368 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/brightness.js @@ -0,0 +1,68 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_brightness_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + brightness(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + var filter = this.Base_layers.find_filter_by_id(filter_id, 'brightness'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 50, range: [-100, 100]}, + ]; + this.show_dialog('brightness', params, filter_id); + } + + convert_value(value) { + var system_value; + if (value > 0) { + system_value = value / 100 + 1; + } + else if (value < 0) { + system_value = value / 100 + 1; + } + else { + system_value = 1; + } + + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(30, null, 'preview'); + ctx.filter = "brightness("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'brightness(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_brightness_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/contrast.js b/paintplus/frontend/src/js/modules/effects/common/contrast.js new file mode 100644 index 0000000..6f9126a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/contrast.js @@ -0,0 +1,69 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_contrast_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + contrast(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'contrast'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 40, range: [-100, 100]}, + ]; + this.show_dialog('contrast', params, filter_id); + } + + convert_value(value) { + var system_value; + if (value > 0) { + system_value = value / 100 + 1; + } + else if (value < 0) { + system_value = value / 100 + 1; + } + else { + system_value = 1; + } + + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(40, null, 'preview'); + ctx.filter = "contrast("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'contrast(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_contrast_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/grayscale.js b/paintplus/frontend/src/js/modules/effects/common/grayscale.js new file mode 100644 index 0000000..a811520 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/grayscale.js @@ -0,0 +1,60 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_grayscale_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + grayscale(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'grayscale'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 100, range: [0, 100]}, + ]; + this.show_dialog('grayscale', params, filter_id); + } + + convert_value(value) { + var system_value = value / 100; + + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(100, null, 'preview'); + ctx.filter = "grayscale("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'grayscale(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_grayscale_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js b/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js new file mode 100644 index 0000000..cff185a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/hue-rotate.js @@ -0,0 +1,58 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_hueRotate_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + hue_rotate(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'hue-rotate'); + + var params = [ + {name: "value", title: "Degree:", value: filter.value ??= 90, range: [0, 360]}, + ]; + this.show_dialog('hue-rotate', params, filter_id); + } + + convert_value(value) { + return value + 'deg'; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(90, null, 'preview'); + ctx.filter = "hue-rotate("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'hue-rotate(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_hueRotate_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/invert.js b/paintplus/frontend/src/js/modules/effects/common/invert.js new file mode 100644 index 0000000..f3ffcb0 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/invert.js @@ -0,0 +1,59 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_invert_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + invert(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'invert'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 100, range: [0, 100]}, + ]; + this.show_dialog('invert', params, filter_id); + } + + convert_value(value) { + var system_value = value / 100; + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(100, null, 'preview'); + ctx.filter = "invert("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'invert(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_invert_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/saturate.js b/paintplus/frontend/src/js/modules/effects/common/saturate.js new file mode 100644 index 0000000..05ccc98 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/saturate.js @@ -0,0 +1,69 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_saturate_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + saturate(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'saturate'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= -50, range: [-100, 100]}, + ]; + this.show_dialog('saturate', params, filter_id); + } + + convert_value(value) { + var system_value; + if (value > 0) { + system_value = value / 100 + 1; + } + else if (value < 0) { + system_value = value / 100 + 1; + } + else { + system_value = 1; + } + + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(-50, null, 'preview'); + ctx.filter = "saturate("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'saturate(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_saturate_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/sepia.js b/paintplus/frontend/src/js/modules/effects/common/sepia.js new file mode 100644 index 0000000..0e8e15b --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/sepia.js @@ -0,0 +1,60 @@ +import Effects_common_class from '../abstract/css.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import config from "../../../config"; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_sepia_class extends Effects_common_class { + + constructor() { + super(); + this.Base_layers = new Base_layers_class(); + } + + sepia(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'sepia'); + + var params = [ + {name: "value", title: "Percentage:", value: filter.value ??= 60, range: [0, 100]}, + ]; + this.show_dialog('sepia', params, filter_id); + } + + convert_value(value) { + var system_value = value / 100; + + return system_value; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(60, null, 'preview'); + ctx.filter = "sepia("+size+")"; + ctx.drawImage(canvas_thumb, 0, 0); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'sepia(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_sepia_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/common/shadow.js b/paintplus/frontend/src/js/modules/effects/common/shadow.js new file mode 100644 index 0000000..e074ce1 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/common/shadow.js @@ -0,0 +1,79 @@ +import config from '../../../config.js'; +import Effects_common_class from '../abstract/css.js'; +import Dialog_class from '../../../libs/popup.js'; +import Effects_browser_class from '../browser.js'; +import Base_layers_class from './../../../core/base-layers.js'; +import alertify from './../../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_brightness_class extends Effects_common_class { + + constructor() { + super(); + this.POP = new Dialog_class(); + this.Effects_browser = new Effects_browser_class(); + this.Base_layers = new Base_layers_class(); + this.preview_padding = 20; + } + + shadow(filter_id) { + if (config.layer.type == null) { + alertify.error('Layer is empty.'); + return; + } + + var filter = this.Base_layers.find_filter_by_id(filter_id, 'shadow'); + + var params = [ + {name: "x", title: "Offset X:", value: filter.x ??= 10, range: [-100, 100]}, + {name: "y", title: "Offset Y:", value: filter.y ??= 10, range: [-100, 100]}, + {name: "value", title: "Radius:", value: filter.value ??= 5, range: [0, 100]}, + {name: "color", title: "Color:", value: filter.color ??= "#000000", type: 'color'}, + ]; + this.show_dialog('shadow', params, filter_id); + } + + convert_value(value, params, type) { + var system_value = value; + + //adapt size to real canvas dimensions + if (type == 'preview') { + var diff = (this.POP.width_mini / this.POP.height_mini) / (config.WIDTH / config.HEIGHT); + + params.x = params.x * (this.POP.width_mini / config.WIDTH); + params.y = params.y * (this.POP.height_mini / config.HEIGHT); + params.value = params.value * diff; + } + + return params.x + "px " + params.y + "px " + params.value + "px " + params.color; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //draw + var size = this.convert_value(null, {x: 5, y: 5, value: 5, color: '#000000'}, 'preview'); + ctx.filter = "drop-shadow("+size+")"; + ctx.drawImage(canvas_thumb, + 10, 10, + this.Effects_browser.preview_width - 20, this.Effects_browser.preview_height - 20); + ctx.filter = 'none'; + } + + render_pre(ctx, data) { + var value = this.convert_value(data.params.value, data.params, 'save'); + var filter = 'drop-shadow(' + value + ')'; + + if(ctx.filter == 'none') + ctx.filter = filter; + else + ctx.filter += ' ' + filter; + } + + render_post(ctx, data){ + ctx.filter = 'none'; + } + +} + +export default Effects_brightness_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/denoise.js b/paintplus/frontend/src/js/modules/effects/denoise.js new file mode 100644 index 0000000..f28d60c --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/denoise.js @@ -0,0 +1,89 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_denoise_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + denoise() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Denoise', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Exponent:", value: 20, range: [0, 50]}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + var data = _this.change(canvas_, params); + canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height); + canvas_preview.drawImage(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, params); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var param1 = parseFloat(params.param1); + + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).denoise(param1).update(); //effect + + return this.fx_filter; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = { + param1: 20, + }; + var data = this.change(canvas_thumb, params); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_denoise_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/dither.js b/paintplus/frontend/src/js/modules/effects/dither.js new file mode 100644 index 0000000..45ffa9a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/dither.js @@ -0,0 +1,82 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_dither_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + dither() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Dither', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Levels:", value: "8", range: [2, 32]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var param1 = parseFloat(params.param1); + + var filtered = ImageFilters.Dither(data, param1); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + param1: 8, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_dither_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/dot_screen.js b/paintplus/frontend/src/js/modules/effects/dot_screen.js new file mode 100644 index 0000000..aebe463 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/dot_screen.js @@ -0,0 +1,89 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_dotScreen_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + dot_screen() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Dot Screen', + preview: true, + effects: true, + params: [ + {name: "size", title: "Size:", value: "3", range: [1, 20]}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + var data = _this.change(canvas_, params); + canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height); + canvas_preview.drawImage(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, params); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var size = parseFloat(params.size); + + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).dotScreen(Math.round(canvas.width / 2), Math.round(canvas.height / 2), 0, size).update(); + + return this.fx_filter; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = { + size: 3, + }; + var data = this.change(canvas_thumb, params); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_dotScreen_class; diff --git a/paintplus/frontend/src/js/modules/effects/edge.js b/paintplus/frontend/src/js/modules/effects/edge.js new file mode 100644 index 0000000..e5c1081 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/edge.js @@ -0,0 +1,55 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_edge_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + edge() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data) { + var filtered = ImageFilters.Edge(data); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_edge_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/emboss.js b/paintplus/frontend/src/js/modules/effects/emboss.js new file mode 100644 index 0000000..281305c --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/emboss.js @@ -0,0 +1,55 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_emboss_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + emboss() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data) { + var filtered = ImageFilters.Emboss(data); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_emboss_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/enrich.js b/paintplus/frontend/src/js/modules/effects/enrich.js new file mode 100644 index 0000000..9b904e5 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/enrich.js @@ -0,0 +1,76 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_enrich_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + enrich() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Enrich', + preview: true, + effects: true, + params: [], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var filtered = ImageFilters.Enrich(data); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = {} + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_enrich_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/grains.js b/paintplus/frontend/src/js/modules/effects/grains.js new file mode 100644 index 0000000..2eeba0f --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/grains.js @@ -0,0 +1,111 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_grains_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + } + + grains() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Grains', + preview: true, + effects: true, + params: [ + {name: "level", title: "Level:", value: "30", range: [0, 50]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + if (params.level == 0) + return data; + var imgData = data.data; + + var H = data.height; + var W = data.width; + + for (var j = 0; j < H; j++) { + for (var i = 0; i < W; i++) { + var x = (i + j * W) * 4; + if (imgData[x + 3] == 0) + continue; //transparent + //increase it's lightness + var delta = this.Helper.getRandomInt(0, params.level); + if (delta == 0) + continue; + + if (imgData[x] - delta < 0) + imgData[x] = -(imgData[x] - delta); + else + imgData[x] = imgData[x] - delta; + if (imgData[x + 1] - delta < 0) + imgData[x + 1] = -(imgData[x + 1] - delta); + else + imgData[x + 1] = imgData[x + 1] - delta; + if (imgData[x + 2] - delta < 0) + imgData[x + 2] = -(imgData[x + 2] - delta); + else + imgData[x + 2] = imgData[x + 2] - delta; + } + } + + return data; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + level: 30, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_grains_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/greyscale.js b/paintplus/frontend/src/js/modules/effects/greyscale.js new file mode 100644 index 0000000..56ac996 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/greyscale.js @@ -0,0 +1,157 @@ +/** + * Greyscale Effect - Convert layer to greyscale (desaturate) + * Useful for CNC carving, depth maps, etc. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Effects_greyscale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + greyscale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster first.'); + return; + } + + var settings = { + title: 'Convert to Greyscale', + preview: true, + effects: true, + params: [ + { + name: "method", + title: "Method:", + values: ["Luminosity (Rec. 709)", "Average", "Lightness", "Red Channel", "Green Channel", "Blue Channel"], + value: "Luminosity (Rec. 709)" + }, + {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]}, + {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]}, + {name: "invert", title: "Invert:", value: false}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_greyscale(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + apply_greyscale(imageData, params) { + var data = imageData.data; + var method = params.method; + var contrast = (params.contrast || 0) / 100; + var brightness = (params.brightness || 0) * 2.55; // Convert to 0-255 range + var invert = params.invert || false; + + // Contrast factor + var factor = (1 + contrast); + + for (var i = 0; i < data.length; i += 4) { + if (data[i + 3] === 0) continue; // Skip transparent pixels + + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + var grey; + + // Calculate greyscale value based on method + switch (method) { + case "Luminosity (Rec. 709)": + // Standard HDTV (Rec. 709) - most accurate perceptual + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + break; + case "Average": + grey = (r + g + b) / 3; + break; + case "Lightness": + // HSL lightness + grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + break; + case "Red Channel": + grey = r; + break; + case "Green Channel": + grey = g; + break; + case "Blue Channel": + grey = b; + break; + default: + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + // Apply brightness + grey += brightness; + + // Apply contrast (around middle grey) + grey = ((grey - 128) * factor) + 128; + + // Invert if requested + if (invert) { + grey = 255 - grey; + } + + // Clamp to valid range + grey = Math.max(0, Math.min(255, Math.round(grey))); + + data[i] = grey; + data[i + 1] = grey; + data[i + 2] = grey; + } + + return imageData; + } + + demo(canvas_id, canvas_thumb) { + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + method: "Luminosity (Rec. 709)", + contrast: 0, + brightness: 0, + invert: false + }; + var data = this.apply_greyscale(img, params); + ctx.putImageData(data, 0, 0); + } +} + +export default Effects_greyscale_class; diff --git a/paintplus/frontend/src/js/modules/effects/heatmap.js b/paintplus/frontend/src/js/modules/effects/heatmap.js new file mode 100644 index 0000000..31d6949 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/heatmap.js @@ -0,0 +1,111 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_heatmap_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + heatmap() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data) { + var imgData = data.data; + var grey, RGB; + + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + RGB = this.color2heat(grey); + imgData[i] = RGB.R; + imgData[i + 1] = RGB.G; + imgData[i + 2] = RGB.B; + } + + return data; + } + + color2heat(value) { + var RGB = {R: 0, G: 0, B: 0}; + value = value / 255; + if (0 <= value && value <= 1 / 8) { + RGB.R = 0; + RGB.G = 0; + RGB.B = 4 * value + .5; // .5 - 1 // b = 1/2 + } + else if (1 / 8 < value && value <= 3 / 8) { + RGB.R = 0; + RGB.G = 4 * value - .5; // 0 - 1 // b = - 1/2 + RGB.B = 1; // small fix + } + else if (3 / 8 < value && value <= 5 / 8) { + RGB.R = 4 * value - 1.5; // 0 - 1 // b = - 3/2 + RGB.G = 1; + RGB.B = -4 * value + 2.5; // 1 - 0 // b = 5/2 + } + else if (5 / 8 < value && value <= 7 / 8) { + RGB.R = 1; + RGB.G = -4 * value + 3.5; // 1 - 0 // b = 7/2 + RGB.B = 0; + } + else if (7 / 8 < value && value <= 1) { + RGB.R = -4 * value + 4.5; // 1 - .5 // b = 9/2 + RGB.G = 0; + RGB.B = 0; + } + else { + // should never happen - value > 1 + RGB.R = .5; + RGB.G = 0; + RGB.B = 0; + } + // scale for hex conversion + RGB.R *= 255; + RGB.G *= 255; + RGB.B *= 255; + + RGB.R = Math.round(RGB.R); + RGB.G = Math.round(RGB.G); + RGB.B = Math.round(RGB.B); + + return RGB; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_heatmap_class; diff --git a/paintplus/frontend/src/js/modules/effects/instagram/1977.js b/paintplus/frontend/src/js/modules/effects/instagram/1977.js new file mode 100644 index 0000000..7b5e10a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/1977.js @@ -0,0 +1,72 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_1977_class { + + constructor() { + this.POP = new Dialog_class(); + //this.Color_matrix = new Color_matrix_class(); + this.Base_layers = new Base_layers_class(); + } + + 1977() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "screen"; + ctx2.fillStyle = 'rgba(243, 106, 188, 0.3)'; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'contrast(1.1) brightness(1.1) saturate(1.3)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_1977_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/aden.js b/paintplus/frontend/src/js/modules/effects/instagram/aden.js new file mode 100644 index 0000000..47c7791 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/aden.js @@ -0,0 +1,74 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_aden_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + aden() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + var gradient = ctx2.createLinearGradient(0, 0, width, height); + gradient.addColorStop(0, "rgba(66, 10, 14, 0.2)"); + gradient.addColorStop(1, "rgba(66, 10, 14, 0.2)"); + ctx2.fillStyle = gradient; + ctx2.fillRect(0, 0, width, height); + + //merge + ctx2.globalCompositeOperation = "darken"; + ctx2.drawImage(canvas, 0, 0); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'hue-rotate(-20deg) contrast(0.9) saturate(0.85) brightness(1.2)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_aden_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js b/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js new file mode 100644 index 0000000..738391e --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/clarendon.js @@ -0,0 +1,72 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_clarendon_class { + + constructor() { + this.POP = new Dialog_class(); + //this.Color_matrix = new Color_matrix_class(); + this.Base_layers = new Base_layers_class(); + } + + clarendon() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.fillStyle = 'rgba(127, 187, 227, 0.2)'; + ctx2.fillRect(0, 0, width, height); + + //merge + ctx2.globalCompositeOperation = "overlay"; + ctx2.drawImage(canvas, 0, 0); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'contrast(1.2) saturate(1.35)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_clarendon_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/gingham.js b/paintplus/frontend/src/js/modules/effects/instagram/gingham.js new file mode 100644 index 0000000..f1fb0aa --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/gingham.js @@ -0,0 +1,71 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_gingham_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + gingham() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "soft-light"; + ctx2.fillStyle = '#e6e6fa'; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'brightness(1.05) hue-rotate(-10deg)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_gingham_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js b/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js new file mode 100644 index 0000000..50f8bab --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/inkwell.js @@ -0,0 +1,69 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +/* +https://github.com/una/CSSgram/blob/master/source/css/toaster.css +https://github.com/vigetlabs/canvas-instagram-filters +*/ +class Effects_inkwell_class { + + constructor() { + this.POP = new Dialog_class(); + //this.Color_matrix = new Color_matrix_class(); + this.Base_layers = new Base_layers_class(); + } + + inkwell() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + + //apply more effects + ctx2.filter = 'sepia(0.3) contrast(1.1) brightness(1.1) grayscale(1)'; + ctx2.drawImage(canvas, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_inkwell_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/lofi.js b/paintplus/frontend/src/js/modules/effects/instagram/lofi.js new file mode 100644 index 0000000..2422dba --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/lofi.js @@ -0,0 +1,75 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_lofi_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + lofi() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "multiply"; + var min = Math.min(width, height); + var gradient = ctx2.createRadialGradient(width / 2, height / 2, min * 0.7, width / 2, height / 2, min * 1.5); + gradient.addColorStop(0, "rgba(0,0,0,0)"); + gradient.addColorStop(1, "#222222"); + ctx2.fillStyle = gradient; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'saturate(1.1) contrast(1.5)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_lofi_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/toaster.js b/paintplus/frontend/src/js/modules/effects/instagram/toaster.js new file mode 100644 index 0000000..95904bb --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/toaster.js @@ -0,0 +1,79 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +/* +https://github.com/una/CSSgram/blob/master/source/css/toaster.css +https://github.com/vigetlabs/canvas-instagram-filters +*/ +class Effects_toaster_class { + + constructor() { + this.POP = new Dialog_class(); + //this.Color_matrix = new Color_matrix_class(); + this.Base_layers = new Base_layers_class(); + } + + toaster() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "screen"; + var gradient = ctx2.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width * 0.6); + gradient.addColorStop(0, "#804e0f"); + gradient.addColorStop(1, "#3b003b"); + ctx2.fillStyle = gradient; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'contrast(1.5) brightness(0.9)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_toaster_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/valencia.js b/paintplus/frontend/src/js/modules/effects/instagram/valencia.js new file mode 100644 index 0000000..ec3991b --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/valencia.js @@ -0,0 +1,71 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_valencia_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + valencia() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "exclusion"; + ctx2.fillStyle = '3a0339'; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'contrast(1.08) brightness(1.08) sepia(0.08)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_valencia_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js b/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js new file mode 100644 index 0000000..8e8eb40 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/instagram/xpro2.js @@ -0,0 +1,75 @@ +import app from '../../../app.js'; +import config from '../../../config.js'; +import Dialog_class from '../../../libs/popup.js'; +import Base_layers_class from '../../../core/base-layers.js'; +import alertify from 'alertifyjs/build/alertify.min.js'; + +class Effects_xpro2_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + xpro2() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + + //create temp canvas + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + //merge + ctx2.globalCompositeOperation = "color-burn"; + var min = Math.min(width, height); + var gradient = ctx2.createRadialGradient(width / 2, height / 2, min * 0.4, width / 2, height / 2, min * 1.1); + gradient.addColorStop(0, "#e6e7e0"); + gradient.addColorStop(1, "rgba(43, 42, 161, 0.6)"); + ctx2.fillStyle = gradient; + ctx2.fillRect(0, 0, width, height); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'sepia(0.3)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_xpro2_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/mosaic.js b/paintplus/frontend/src/js/modules/effects/mosaic.js new file mode 100644 index 0000000..08841f5 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/mosaic.js @@ -0,0 +1,86 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_mosaic_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + mosaic() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Mosaic', + preview: true, + effects: true, + params: [ + {name: "size", title: "Size:", value: 10, range: [1, 100]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var size = parseFloat(params.size); + + //convert % to px + size = Math.min(data.width, data.height) * size / 100; + size = Math.round(size); + + var filtered = ImageFilters.Mosaic(data, size); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + size: 10, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_mosaic_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/night_vision.js b/paintplus/frontend/src/js/modules/effects/night_vision.js new file mode 100644 index 0000000..dfe29ef --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/night_vision.js @@ -0,0 +1,82 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import ImageFilters_class from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_nightVision_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + this.ImageFilters = ImageFilters_class; + } + + night_vision() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + //create second copy + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, 0, 0); + + // green overlay + var img = ctx2.getImageData(0, 0, width, height); + //RGB corrections + var img = this.ImageFilters.ColorTransformFilter(img, 1, 1, 1, 1, 0, 100, 0, 1); + //hue/saturation/luminance + var img = this.ImageFilters.HSLAdjustment(img, 0, 0, -50); + ctx2.putImageData(img, 0, 0); + + //vignete + var texture = this.fx_filter.texture(canvas2); + this.fx_filter.draw(texture).vignette(0.2, 0.9).update(); //effect + canvas2 = this.fx_filter; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = {}; + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_nightVision_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/oil.js b/paintplus/frontend/src/js/modules/effects/oil.js new file mode 100644 index 0000000..7c52056 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/oil.js @@ -0,0 +1,85 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_oil_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + oil() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Oil', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Range:", value: 2, range: [1, 10]}, + {name: "param2", title: "Levels:", value: "32", range: [1, 256]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var param1 = parseFloat(params.param1); + var param2 = parseInt(params.param2); + + var filtered = ImageFilters.Oil(data, param1, param2); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + param1: 2, + param2: 32, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_oil_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/pencil.js b/paintplus/frontend/src/js/modules/effects/pencil.js new file mode 100644 index 0000000..9837cea --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/pencil.js @@ -0,0 +1,73 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_pencil_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + pencil() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, canvas.width, canvas.height); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, width, height) { + var offset = Math.min(width, height) / 1000; + offset = Math.ceil(offset); + + //create second copy + var canvas2 = document.createElement('canvas'); + var ctx2 = canvas2.getContext("2d"); + canvas2.width = width; + canvas2.height = height; + ctx2.drawImage(canvas, -offset, -offset); + + //merge + ctx2.globalCompositeOperation = "difference"; + ctx2.drawImage(canvas, 0, 0); + ctx2.globalCompositeOperation = "source-over"; + + //apply more effects + ctx2.filter = 'brightness(2) invert(1) grayscale(1)'; + ctx2.drawImage(canvas2, 0, 0); + ctx2.filter = 'none'; + + return canvas2; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = {}; + var data = this.change(canvas_thumb, canvas_thumb.width, canvas_thumb.height); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_pencil_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/sharpen.js b/paintplus/frontend/src/js/modules/effects/sharpen.js new file mode 100644 index 0000000..d699e8a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/sharpen.js @@ -0,0 +1,82 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_sharpen_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + sharpen() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Sharpen', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Factor:", value: "3", range: [1, 10], step: 0.1}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, params) { + var param1 = parseFloat(params.param1); + + var filtered = ImageFilters.Sharpen(data, param1); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var params = { + param1: 3, + } + var data = this.change(img, params); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_sharpen_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/solarize.js b/paintplus/frontend/src/js/modules/effects/solarize.js new file mode 100644 index 0000000..fef96f7 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/solarize.js @@ -0,0 +1,55 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_solarize_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + solarize() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data) { + var filtered = ImageFilters.Solarize(data); + + return filtered; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var img = ctx.getImageData(0, 0, canvas_thumb.width, canvas_thumb.height); + var data = this.change(img); + ctx.putImageData(data, 0, 0); + } + +} + +export default Effects_solarize_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/tilt_shift.js b/paintplus/frontend/src/js/modules/effects/tilt_shift.js new file mode 100644 index 0000000..4fa8826 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/tilt_shift.js @@ -0,0 +1,144 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_tiltShift_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + tilt_shift() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Tilt Shift', + preview: true, + effects: true, + params: [ + //extra + {name: "param7", title: "Saturation:", value: "3", range: [0, 20]}, + {name: "param8", title: "Sharpen:", value: "1", range: [0, 5]}, + //main + {name: "param1", title: "Blur Radius:", value: 10, range: [0, 30]}, + {name: "param2", title: "Gradient Radius:", value: 70, range: [40, 100]}, + //startX, startY, endX, endY + {name: "param3", title: "X start:", value: 0, range: [0, 100]}, + {name: "param4", title: "Y start:", value: 50, range: [0, 100]}, + {name: "param5", title: "X end:", value: 100, range: [0, 100]}, + {name: "param6", title: "Y end:", value: 50, range: [0, 100]}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + //recalc param by size + _this.change(canvas_, params); + + //convert % to px for line + params.param3 = canvas_.width * params.param3 / 100; + params.param4 = canvas_.height * params.param4 / 100; + params.param5 = canvas_.width * params.param5 / 100; + params.param6 = canvas_.height * params.param6 / 100; + + //draw line + canvas_preview.beginPath(); + canvas_preview.strokeStyle = "#ff0000"; + canvas_preview.lineWidth = 1; + canvas_preview.moveTo(params.param3 + 0.5, params.param4 + 0.5); + canvas_preview.lineTo(params.param5 + 0.5, params.param6 + 0.5); + canvas_preview.stroke(); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + this.change(canvas, params); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var param1 = parseInt(params.param1); + var param2 = parseInt(params.param2); + var param3 = parseInt(params.param3); + var param4 = parseInt(params.param4); + var param5 = parseInt(params.param5); + var param6 = parseInt(params.param6); + var param7 = parseInt(params.param7); + var param8 = parseInt(params.param8); + + //convert % to px + param1 = canvas.height * param1 / 100; + param2 = canvas.height * param2 / 100; + param3 = canvas.width * param3 / 100; + param4 = canvas.height * param4 / 100; + param5 = canvas.width * param5 / 100; + param6 = canvas.height * param6 / 100; + + var ctx = canvas.getContext("2d"); + + //main effect + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).tiltShift(param3, param4, param5, param6, param1, param2).update(); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(this.fx_filter, 0, 0); + + //saturation + var data = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = ImageFilters.HSLAdjustment(data, 0, param7, 0); + ctx.putImageData(data, 0, 0); + + //sharpen + var data = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = ImageFilters.Sharpen(data, param8); + ctx.putImageData(data, 0, 0); + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var params = { + param7: 3, + param8: 1, + param1: 10, + param2: 70, + param3: 0, + param4: 50, + param5: 100, + param6: 50, + } + var data = this.change(canvas, params); + } + +} + +export default Effects_tiltShift_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/vibrance.js b/paintplus/frontend/src/js/modules/effects/vibrance.js new file mode 100644 index 0000000..56c31aa --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/vibrance.js @@ -0,0 +1,89 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_vibrance_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + vibrance() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Vibrance', + preview: true, + effects: true, + params: [ + {name: "level", title: "Level:", value: "0.5", range: [-1, 1], step: 0.01}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + var data = _this.change(canvas_, params); + canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height); + canvas_preview.drawImage(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, params); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var param1 = parseFloat(params.level); + + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).vibrance(param1).update(); //effect + + return this.fx_filter; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = { + level: 0.5, + }; + var data = this.change(canvas_thumb, params); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_vibrance_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/vignette.js b/paintplus/frontend/src/js/modules/effects/vignette.js new file mode 100644 index 0000000..e694ae2 --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/vignette.js @@ -0,0 +1,92 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_vignette_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + vignette() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Vignette', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Level:", value: "0.5", range: [0, 1], step: 0.01}, + {name: "param2", title: "Size:", value: "0.5", range: [0, 1], step: 0.01}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + var data = _this.change(canvas_, params); + canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height); + canvas_preview.drawImage(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, params); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var param1 = parseFloat(params.param1); + var param2 = parseFloat(params.param2); + + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).vignette(param1, param2).update(); //effect + + return this.fx_filter; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = { + param1: 0.5, + param2: 0.5, + }; + var data = this.change(canvas_thumb, params); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_vignette_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/vintage.js b/paintplus/frontend/src/js/modules/effects/vintage.js new file mode 100644 index 0000000..da8ab2a --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/vintage.js @@ -0,0 +1,77 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Vintage_class from './../../libs/vintage.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_vintage_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Vintage = new Vintage_class(config.WIDTH, config.HEIGHT); + } + + vintage() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + this.Vintage.reset_random_values(config.WIDTH, config.HEIGHT); + + var settings = { + title: 'Vintage', + preview: true, + effects: true, + params: [ + {name: "level", title: "Level:", value: 50, range: [0, 100]}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + _this.change(canvas_, params); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + this.change(canvas, params); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + var level = parseInt(params.level); + + this.Vintage.apply_all(canvas, level); + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + ctx.drawImage(canvas_thumb, 0, 0); + + //now update + var params = { + level: 50, + }; + this.change(canvas, params); + } + +} + +export default Effects_vintage_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/effects/zoom_blur.js b/paintplus/frontend/src/js/modules/effects/zoom_blur.js new file mode 100644 index 0000000..3e1459f --- /dev/null +++ b/paintplus/frontend/src/js/modules/effects/zoom_blur.js @@ -0,0 +1,102 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import glfx from './../../libs/glfx.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Effects_zoomBlur_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + } + + zoom_blur() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get layer size + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + + var settings = { + title: 'Zoom blur', + preview: true, + effects: true, + params: [ + {name: "param1", title: "Strength:", value: "0.3", range: [0, 1], step: 0.01}, + {name: "param2", title: "Center x:", value: Math.round(canvas.width / 2), range: [0, canvas.width]}, + {name: "param3", title: "Center y:", value: Math.round(canvas.height / 2), range: [0, canvas.height]}, + ], + on_change: function (params, canvas_preview, w, h, canvas_) { + //recalc param by size + params.param2 = params.param2 / canvas.width * w; + params.param3 = params.param3 / canvas.height * h; + + var data = _this.change(canvas_, params); + canvas_preview.clearRect(0, 0, canvas_.width, canvas_.height); + canvas_preview.drawImage(data, 0, 0); + }, + on_finish: function (params) { + _this.save(params); + }, + }; + this.POP.show(settings); + } + + save(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var data = this.change(canvas, params); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(canvas, params) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var param1 = parseFloat(params.param1); + var param2 = parseInt(params.param2); + var param3 = parseInt(params.param3); + + var texture = this.fx_filter.texture(canvas); + this.fx_filter.draw(texture).zoomBlur(param2, param3, param1).update(); //effect + + return this.fx_filter; + } + + demo(canvas_id, canvas_thumb){ + var canvas = document.getElementById(canvas_id); + var ctx = canvas.getContext("2d"); + + //modify + var params = { + param1: 0.3, + param2: Math.round(canvas_thumb.width / 2), + param3: Math.round(canvas_thumb.height / 2), + }; + var data = this.change(canvas_thumb, params); + + //draw + ctx.drawImage(data, 0, 0); + } + +} + +export default Effects_zoomBlur_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/file/my_library.js b/paintplus/frontend/src/js/modules/file/my_library.js new file mode 100644 index 0000000..40d9574 --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/my_library.js @@ -0,0 +1,504 @@ +/** + * My Library - Save and reuse your own assets (clip art, templates, etc.) + * Assets are stored in browser's IndexedDB for persistence + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +// IndexedDB setup +const DB_NAME = 'miniPaintLibrary'; +const DB_VERSION = 1; +const STORE_NAME = 'assets'; + +class File_my_library_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.db = null; + + this.initDB(); + } + + /** + * Initialize IndexedDB + */ + initDB() { + var _this = this; + + var request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = function(event) { + console.error('IndexedDB error:', event); + }; + + request.onsuccess = function(event) { + _this.db = event.target.result; + }; + + request.onupgradeneeded = function(event) { + var db = event.target.result; + + if (!db.objectStoreNames.contains(STORE_NAME)) { + var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); + store.createIndex('name', 'name', { unique: false }); + store.createIndex('category', 'category', { unique: false }); + store.createIndex('created', 'created', { unique: false }); + } + }; + } + + /** + * Save current layer as a library asset + */ + save_to_library() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer to save'); + return; + } + + var settings = { + title: 'Save to My Library', + params: [ + { + name: "name", + title: "Asset Name:", + value: config.layer.name || "My Asset" + }, + { + name: "category", + title: "Category:", + value: "General", + values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"] + }, + { + name: "description", + title: "Description:", + value: "" + } + ], + on_finish: function (params) { + _this.do_save_to_library(params); + }, + }; + this.POP.show(settings); + } + + /** + * Save current selection to library (uses mask if available) + */ + save_selection_to_library() { + var _this = this; + + if (!window.smartSelectMask || !window.smartSelectMask.canvas) { + alertify.error('No selection. Use a selection tool first.'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Save Selection to Library', + params: [ + { + name: "name", + title: "Asset Name:", + value: "Selection Asset" + }, + { + name: "category", + title: "Category:", + value: "General", + values: ["General", "Shapes", "Borders", "Icons", "Templates", "Text Elements", "Backgrounds", "Other"] + }, + { + name: "description", + title: "Description:", + value: "" + } + ], + on_finish: function (params) { + _this.do_save_selection_to_library(params); + }, + }; + this.POP.show(settings); + } + + do_save_to_library(params) { + var _this = this; + + // Get canvas from current layer + var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, true, false); + + // Create thumbnail (max 150px) + var thumbCanvas = document.createElement('canvas'); + var maxSize = 150; + var scale = Math.min(maxSize / canvas.width, maxSize / canvas.height); + thumbCanvas.width = Math.round(canvas.width * scale); + thumbCanvas.height = Math.round(canvas.height * scale); + var thumbCtx = thumbCanvas.getContext('2d'); + thumbCtx.drawImage(canvas, 0, 0, thumbCanvas.width, thumbCanvas.height); + + var asset = { + name: params.name, + category: params.category, + description: params.description, + width: canvas.width, + height: canvas.height, + data: canvas.toDataURL('image/png'), + thumbnail: thumbCanvas.toDataURL('image/png'), + created: new Date().toISOString() + }; + + this.saveAsset(asset, function() { + alertify.success('Saved "' + params.name + '" to library!'); + }); + } + + do_save_selection_to_library(params) { + var _this = this; + var layer = config.layer; + var maskCanvas = window.smartSelectMask.canvas; + + // Create canvas with just the selected pixels + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(maskCanvas, 0, 0); + + // Find bounds of selection + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = imageData.data; + var minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; + + for (var y = 0; y < canvas.height; y++) { + for (var x = 0; x < canvas.width; x++) { + var i = (y * canvas.width + x) * 4; + if (data[i + 3] > 0) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + // Crop to selection bounds + var cropWidth = maxX - minX + 1; + var cropHeight = maxY - minY + 1; + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + croppedCtx.drawImage(canvas, minX, minY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight); + + // Create thumbnail + var thumbCanvas = document.createElement('canvas'); + var maxSize = 150; + var scale = Math.min(maxSize / cropWidth, maxSize / cropHeight); + thumbCanvas.width = Math.round(cropWidth * scale); + thumbCanvas.height = Math.round(cropHeight * scale); + var thumbCtx = thumbCanvas.getContext('2d'); + thumbCtx.drawImage(croppedCanvas, 0, 0, thumbCanvas.width, thumbCanvas.height); + + var asset = { + name: params.name, + category: params.category, + description: params.description, + width: cropWidth, + height: cropHeight, + data: croppedCanvas.toDataURL('image/png'), + thumbnail: thumbCanvas.toDataURL('image/png'), + created: new Date().toISOString() + }; + + this.saveAsset(asset, function() { + alertify.success('Saved selection "' + params.name + '" to library!'); + }); + } + + saveAsset(asset, callback) { + if (!this.db) { + alertify.error('Database not ready, please try again'); + return; + } + + var transaction = this.db.transaction([STORE_NAME], 'readwrite'); + var store = transaction.objectStore(STORE_NAME); + var request = store.add(asset); + + request.onsuccess = function() { + if (callback) callback(); + }; + + request.onerror = function(event) { + alertify.error('Error saving asset'); + console.error('Save error:', event); + }; + } + + /** + * Browse and insert assets from library + */ + browse_library() { + var _this = this; + + this.getAllAssets(function(assets) { + _this.showLibraryBrowser(assets); + }); + } + + getAllAssets(callback) { + if (!this.db) { + alertify.error('Database not ready'); + callback([]); + return; + } + + var transaction = this.db.transaction([STORE_NAME], 'readonly'); + var store = transaction.objectStore(STORE_NAME); + var request = store.getAll(); + + request.onsuccess = function(event) { + callback(event.target.result || []); + }; + + request.onerror = function() { + callback([]); + }; + } + + showLibraryBrowser(assets) { + var _this = this; + + if (assets.length === 0) { + alertify.warning('Your library is empty. Save some assets first!'); + return; + } + + // Group by category + var categories = {}; + assets.forEach(function(asset) { + var cat = asset.category || 'General'; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(asset); + }); + + // Build HTML for the browser + var html = '
    '; + html += '
    '; + + for (var cat in categories) { + html += '
    '; + html += '

    ' + cat + '

    '; + html += '
    '; + + categories[cat].forEach(function(asset) { + html += '
    '; + html += '' + asset.name + ''; + html += '
    ' + asset.name + '
    '; + html += '
    '; + html += ''; + html += ''; + html += '
    '; + html += '
    '; + }); + + html += '
    '; + } + + html += '
    '; + + // Show in dialog + var settings = { + title: 'My Library (' + assets.length + ' assets)', + params: [], + html: html, + className: 'wide', + on_load: function(el) { + // Add click handlers + el.querySelectorAll('.insert-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + _this.insertAsset(id); + _this.POP.hide(); + }); + }); + + el.querySelectorAll('.delete-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + if (confirm('Delete this asset?')) { + _this.deleteAsset(id, function() { + alertify.success('Asset deleted'); + _this.POP.hide(); + }); + } + }); + }); + + // Double-click to insert + el.querySelectorAll('.library-item').forEach(function(item) { + item.addEventListener('dblclick', function() { + var id = parseInt(this.dataset.id); + _this.insertAsset(id); + _this.POP.hide(); + }); + }); + } + }; + + this.POP.show(settings); + } + + insertAsset(id) { + var _this = this; + + var transaction = this.db.transaction([STORE_NAME], 'readonly'); + var store = transaction.objectStore(STORE_NAME); + var request = store.get(id); + + request.onsuccess = function(event) { + var asset = event.target.result; + if (asset) { + _this.createLayerFromAsset(asset); + } + }; + } + + createLayerFromAsset(asset) { + var _this = this; + + var img = new Image(); + img.onload = function() { + // Insert as new layer + var params = { + x: Math.round((config.WIDTH - asset.width) / 2), + y: Math.round((config.HEIGHT - asset.height) / 2), + width: asset.width, + height: asset.height, + width_original: asset.width, + height_original: asset.height, + type: 'image', + name: asset.name, + data: asset.data + }; + + app.State.do_action( + new app.Actions.Bundle_action('insert_library_asset', 'Insert Library Asset', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Inserted "' + asset.name + '"'); + }; + img.src = asset.data; + } + + deleteAsset(id, callback) { + var transaction = this.db.transaction([STORE_NAME], 'readwrite'); + var store = transaction.objectStore(STORE_NAME); + var request = store.delete(id); + + request.onsuccess = function() { + if (callback) callback(); + }; + } + + /** + * Export library to JSON file (backup) + */ + export_library() { + var _this = this; + + this.getAllAssets(function(assets) { + if (assets.length === 0) { + alertify.warning('Library is empty'); + return; + } + + var data = JSON.stringify(assets, null, 2); + var blob = new Blob([data], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + + var a = document.createElement('a'); + a.href = url; + a.download = 'my-library-backup.json'; + a.click(); + + URL.revokeObjectURL(url); + alertify.success('Library exported!'); + }); + } + + /** + * Import library from JSON file + */ + import_library() { + var _this = this; + + var input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.onchange = function(e) { + var file = e.target.files[0]; + if (!file) return; + + var reader = new FileReader(); + reader.onload = function(event) { + try { + var assets = JSON.parse(event.target.result); + + if (!Array.isArray(assets)) { + alertify.error('Invalid library file'); + return; + } + + var imported = 0; + assets.forEach(function(asset) { + // Remove id so it gets auto-assigned + delete asset.id; + _this.saveAsset(asset, function() { + imported++; + if (imported === assets.length) { + alertify.success('Imported ' + imported + ' assets!'); + } + }); + }); + + } catch (err) { + alertify.error('Error parsing library file'); + console.error(err); + } + }; + reader.readAsText(file); + }; + + input.click(); + } +} + +export default File_my_library_class; diff --git a/paintplus/frontend/src/js/modules/file/new.js b/paintplus/frontend/src/js/modules/file/new.js new file mode 100644 index 0000000..bca419f --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/new.js @@ -0,0 +1,137 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import Dialog_class from './../../libs/popup.js'; +import Tools_settings_class from './../tools/settings.js'; + +/** + * manages files / new + * + * @author ViliusL + */ +class File_new_class { + + constructor() { + this.Base_gui = new Base_gui_class(); + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.Tools_settings = new Tools_settings_class(); + } + + new () { + var _this = this; + var width = config.WIDTH; + var height = config.HEIGHT; + var common_dimensions = this.Base_gui.common_dimensions; + var resolution_types = ['Custom']; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + for (var i in common_dimensions) { + var value = common_dimensions[i]; + resolution_types.push(value[0] + 'x' + value[1] + ' - ' + value[2]); + } + + var transparency_cookie = this.Helper.getCookie('transparency'); + if (transparency_cookie === null) { + //default + transparency_cookie = false; + } + if (transparency_cookie) { + var transparency = true; + } + else { + var transparency = false; + } + + //convert units + width = this.Helper.get_user_unit(width, units, resolution); + height = this.Helper.get_user_unit(height, units, resolution); + + var settings = { + title: 'New file', + params: [ + {name: "width", title: "Width:", value: width, comment: units}, + {name: "height", title: "Height:", value: height, comment: units}, + {name: "resolution_type", title: "Resolution:", values: resolution_types}, + {name: "layout", title: "Layout:", value: "Custom", values: ["Custom", "Landscape", "Portrait"]}, + {name: "transparency", title: "Transparent:", value: transparency}, + ], + on_finish: function (params) { + _this.new_handler(params); + }, + }; + this.POP.show(settings); + } + + async new_handler(response) { + var width = parseFloat(response.width); + var height = parseFloat(response.height); + var resolution_type = response.resolution_type; + var transparency = response.transparency; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + if (resolution_type != 'Custom') { + var dim = resolution_type.split(" "); + dim = dim[0].split("x"); + width = parseInt(dim[0]); + height = parseInt(dim[1]); + + if(response.layout == 'Portrait'){ + var tmp = width; + width = height; + height = tmp; + } + } + else { + //convert units + width = this.Helper.get_internal_unit(width, units, resolution); + height = this.Helper.get_internal_unit(height, units, resolution); + } + + // Prepare layers + app.State.do_action( + new app.Actions.Bundle_action('new_file', 'New File', [ + new app.Actions.Refresh_action_attributes_action('undo'), + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + TRANSPARENCY: !!transparency, + WIDTH: parseInt(width), + HEIGHT: parseInt(height), + ALPHA: 255, + COLOR: '#008000', + mouse: {}, + visible_width: null, + visible_height: null, + user_fonts: {} + }), + new app.Actions.Prepare_canvas_action('do'), + new app.Actions.Refresh_action_attributes_action('do'), + new app.Actions.Reset_layers_action(), + new app.Actions.Init_canvas_zoom_action(), + new app.Actions.Insert_layer_action({}) + ]) + ); + + //sleep, lets wait till DOM is finished + await new Promise(r => setTimeout(r, 10)); + + //fit to screen? + this.Base_gui.GUI_preview.zoom_auto(true); + + // Save transparency + if (transparency) { + this.Helper.setCookie('transparency', 1); + } + else { + this.Helper.setCookie('transparency', 0); + } + } + +} + +export default File_new_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/file/open.js b/paintplus/frontend/src/js/modules/file/open.js new file mode 100644 index 0000000..494992b --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/open.js @@ -0,0 +1,708 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import Clipboard_class from './../../libs/clipboard.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import EXIF from './../../../../node_modules/exif-js/exif.js'; +import GUI_tools_class from "../../core/gui/gui-tools"; +import semver_compare from './../../../../node_modules/semver-compare/'; + +var instance = null; + +/** + * manages files / open + * + * @author ViliusL + */ +class File_open_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + var _this = this; + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.GUI_tools = new GUI_tools_class(); + + //clipboard class + this.Clipboard_class = new Clipboard_class(function (data, w, h) { + _this.on_paste(data, w, h); + }); + + this.events(); + + this.maybe_file_open_url_handler(); + } + + events() { + var _this = this; + + window.ondrop = function (e) { + //drop + e.preventDefault(); + _this.open_handler(e); + }; + window.ondragover = function (e) { + e.preventDefault(); + }; + document.addEventListener('keydown', (event) => { + var code = event.key.toLowerCase(); + if (this.Helper.is_input(event.target)) + return; + + if (code == "o") { + //open + this.open_file(); + event.preventDefault(); + } + }, false); + } + + on_paste(data, width, height) { + var new_layer = { + name: 'Paste', + type: 'image', + data: data, + }; + app.State.do_action( + new app.Actions.Insert_layer_action(new_layer) + ); + } + + open_file() { + var _this = this; + + alertify.success('You can also drag and drop items into browser.'); + + document.getElementById("tmp").innerHTML = ''; + var a = document.createElement('input'); + a.setAttribute("id", "file_open"); + a.type = 'file'; + a.multiple = 'multiple'; + document.getElementById("tmp").appendChild(a); + document.getElementById('file_open').addEventListener('change', function (e) { + _this.open_handler(e); + }, false); + + //force click + document.querySelector('#file_open').click(); + } + + open_webcam(){ + var _this = this; + var video = document.createElement('video'); + video.autoplay = true; + video.style.maxWidth = '100%'; + var track = null; + + function handleSuccess(stream) { + track = stream.getTracks()[0]; + video.srcObject = stream; + } + + function handleError(error) { + alertify.error('Sorry, cold not load getUserMedia() data: ' + error); + } + + var settings = { + title: 'Webcam', + params: [ + {title: "Stream:", html: '
    '}, + ], + on_load: function(params){ + document.getElementById('webcam_container').appendChild(video); + }, + on_finish: function(params){ + //capture data + var width = video.videoWidth; + var height = video.videoHeight; + + var tmpCanvas = document.createElement('canvas'); + var tmpCanvasCtx = tmpCanvas.getContext("2d"); + tmpCanvas.width = width; + tmpCanvas.height = height; + tmpCanvasCtx.drawImage(video, 0, 0); + + //create requested layer + var new_layer = { + name: "Webcam #" + _this.Base_layers.auto_increment, + type: 'image', + data: tmpCanvas.toDataURL("image/png"), + width: width, + height: height, + width_original: width, + height_original: height, + }; + app.State.do_action( + new app.Actions.Bundle_action('open_file_webcam', 'Open File Webcam', [ + new app.Actions.Insert_layer_action(new_layer), + new app.Actions.Autoresize_canvas_action(width, height, null, true, true) + ]) + ); + + //destroy + if(track != null){ + track.stop(); + } + video.pause(); + video.src = ""; + video.load(); + }, + on_cancel: function(params){ + if(track != null){ + track.stop(); + } + video.pause(); + video.src = ""; + video.load(); + }, + }; + this.POP.show(settings); + + navigator.mediaDevices.getUserMedia({audio: false, video: true}) + .then(handleSuccess) + .catch(handleError); + } + + open_dir() { + var _this = this; + + document.getElementById("tmp").innerHTML = ''; + var a = document.createElement('input'); + a.setAttribute("id", "file_open_dir"); + a.type = 'file'; + a.webkitdirectory = 'webkitdirectory'; + document.getElementById("tmp").appendChild(a); + document.getElementById('file_open_dir').addEventListener('change', function (e) { + _this.open_handler(e); + }, false); + + //force click + document.querySelector('#file_open_dir').click(); + } + + /** + * opens data URLs, like: "data:image/png;base64,xxxxxx" + * + * data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAG0lEQVQYV2N89+7df0FBQQbG/////3///j0DAF9wCsg9spQfAAAAAElFTkSuQmCC + */ + open_data_url() { + var _this = this; + + var settings = { + title: 'Open data URL', + params: [ + {name: "data", title: "Data URL:", type: "textarea", value: ""}, + ], + on_finish: function (params) { + _this.file_open_data_url_handler(params.data); + }, + }; + this.POP.show(settings); + } + + file_open_data_url_handler(data) { + var _this = this; + if (data == '') + return; + + var img = new Image(); + img.crossOrigin = "Anonymous"; + img.onload = function () { + var new_layer = { + name: "Data URL", + type: 'image', + link: img, + width: img.width, + height: img.height, + width_original: img.width, + height_original: img.height, + }; + app.State.do_action( + new app.Actions.Bundle_action('open_file_data_url', 'Open File Data URL', [ + new app.Actions.Insert_layer_action(new_layer), + new app.Actions.Autoresize_canvas_action(img.width, img.height, null, true, true) + ]) + ); + img.onload = function () { + config.need_render = true; + }; + }; + img.onerror = function (ex) { + alertify.error('Sorry, image could not be loaded. Try copy image and paste it.'); + }; + img.src = data; + } + + open_url() { + var _this = this; + + var settings = { + title: 'Open URL', + params: [ + {name: "url", title: "URL:", value: ""}, + ], + on_finish: function (params) { + _this.file_open_url_handler(params); + }, + }; + this.POP.show(settings); + } + + async open_handler(e) { + var _this = this; + var files = e.target.files; + + var auto_increment = this.Base_layers.auto_increment; + + if (files == undefined) { + //drag and drop + files = e.dataTransfer.files; + } + + //sort + var orders = []; + for (var i = 0, f; i < files.length; i++) { + orders.push(files[i].name); + } + orders.sort(); + var order_map = []; + for (var i in orders) { + order_map[orders[i]] = parseInt(i); + } + + //check if dropped directory + var dir_opened = false; + if (e.dataTransfer && e.dataTransfer.items) { + var items = e.dataTransfer.items; + for (var i=0; i setTimeout(r, 10)); + } + + //try to open dropped directory + if (e.dataTransfer && e.dataTransfer.items) { + var items = e.dataTransfer.items; + for (var i=0; i setTimeout(r, 10)); + + }); + } + else if (item.isDirectory) { + // Get folder contents + var dirReader = item.createReader(); + dirReader.readEntries(function(entries) { + for (var i=0; i max_id_order) + max_id_order = value.id; + if(typeof value.order != undefined && value.order > max_id_order) + max_id_order = value.order; + + if (value.type == 'image') { + //add image data + value.link = null; + for (var j in json.data) { + if (json.data[j].id == value.id) { + value.data = json.data[j].data; + } + } + } + actions.push( + new app.Actions.Insert_layer_action(value, false) + ); + } + if (json.info.layer_active != undefined) { + actions.push( + new app.Actions.Select_layer_action(json.info.layer_active, true) + ); + } + if (json.info.guides != undefined) { + config.guides = json.info.guides; + } + actions.push( + new app.Actions.Set_object_property_action(this.Base_layers, 'auto_increment', max_id_order + 1), + new app.Actions.Update_config_action({ + WIDTH: parseInt(json.info.width), + HEIGHT: parseInt(json.info.height), + }), + new app.Actions.Prepare_canvas_action('do') + ); + await app.State.do_action( + new app.Actions.Bundle_action('open_json_file', 'Open JSON File', actions) + ); + } + + /** + * Returns an action that saves the exif data of the provided object to the current layer + */ + extract_exif(object) { + var exif_data = { + general: [], + exif: [], + }; + + //exif data + EXIF.getData(object, function () { + exif_data.exif = this.exifdata; + delete this.exifdata.thumbnail; + }); + + //general + if (object.name != undefined) + exif_data.general.Name = object.name; + if (object.size != undefined) + exif_data.general.Size = this.Helper.number_format(object.size / 1000, 2) + ' KB'; + if (object.type != undefined) + exif_data.general.Type = object.type; + if (object.lastModified != undefined) + exif_data.general['Last modified'] = this.Helper.format_time(object.lastModified); + + return exif_data; + } + + search(){ + this.GUI_tools.activate_tool('media'); + } +} + +export default File_open_class; + diff --git a/paintplus/frontend/src/js/modules/file/print.js b/paintplus/frontend/src/js/modules/file/print.js new file mode 100644 index 0000000..dbfc77f --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/print.js @@ -0,0 +1,14 @@ +/** + * manages files / print + * + * @author ViliusL + */ +class File_print_class { + + print() { + window.print(); + } + +} + +export default File_print_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/file/quickload.js b/paintplus/frontend/src/js/modules/file/quickload.js new file mode 100644 index 0000000..c78fae8 --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/quickload.js @@ -0,0 +1,46 @@ +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import File_open_class from './open.js'; + +/** + * manages files / quick-load + * + * @author ViliusL + */ +class File_quickload_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + this.File_open = new File_open_class(); + + this.set_events(); + } + + set_events() { + var _this = this; + + document.addEventListener('keydown', function (event) { + var code = event.keyCode; + + if (code == 121) { + //F10 + _this.quickload(); + event.preventDefault(); + } + }, false); + } + + quickload() { + //load image data + var json = localStorage.getItem('quicksave_data'); + if (json == '' || json == null) { + //nothing was found + return false; + } + + this.File_open.load_json(json); + } + +} + +export default File_quickload_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/file/quicksave.js b/paintplus/frontend/src/js/modules/file/quicksave.js new file mode 100644 index 0000000..729c014 --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/quicksave.js @@ -0,0 +1,45 @@ +import config from './../../config.js'; +import File_save_class from './save.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +/** + * manages files / quick-save + * + * @author ViliusL + */ +class File_quicksave_class { + + constructor() { + this.POP = new Dialog_class(); + this.File_save = new File_save_class(); + + this.set_events(); + } + + set_events() { + var _this = this; + + document.addEventListener('keydown', function (event) { + var code = event.keyCode; + + if (code == 120) { + //F9 + _this.quicksave(); + } + }, false); + } + + quicksave() { + //save image data + var data_json = this.File_save.export_as_json(); + if (data_json.length > 5000000) { + alertify.error('Sorry, image is too big, max 5 MB.'); + return false; + } + localStorage.setItem('quicksave_data', data_json); + } + +} + +export default File_quicksave_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/file/save.js b/paintplus/frontend/src/js/modules/file/save.js new file mode 100644 index 0000000..241dfe5 --- /dev/null +++ b/paintplus/frontend/src/js/modules/file/save.js @@ -0,0 +1,827 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import canvasToBlob from './../../../../node_modules/blueimp-canvas-to-blob/js/canvas-to-blob.min.js'; +import filesaver from './../../../../node_modules/file-saver/dist/FileSaver.min.js'; +import GIF from './../../../../node_modules/gif.js.optimized/'; +import CanvasToTIFF from './../../libs/canvastotiff.js'; +import TiffWriter from './../../libs/tiff-writer.js'; +import PdfWriter from './../../libs/pdf-writer.js'; +import Tools_settings_class from "../tools/settings"; + +var instance = null; + +/** + * manages files / save + * + * @author ViliusL + */ +class File_save_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.POP = new Dialog_class(); + this.Tools_settings = new Tools_settings_class(); + + this.set_events(); + + //save types config + this.SAVE_TYPES = { + PNG: "Portable Network Graphics", + JPG: "JPG/JPEG Format", + //AVIF: "AV1 Image File Format", //just uncomment it in future to make it work + JSON: "Full layers data", + WEBP: "Weppy File Format", + GIF: "Graphics Interchange Format", + BMP: "Windows Bitmap", + TIFF: "TIFF (RGBA)", + TIFF_CMYK: "TIFF (CMYK, print)", + TIFF_LAYERS: "TIFF (Multilayer)", + PDF: "PDF Document (RGB)", + PDF_CMYK: "PDF Document (CMYK, print)", + }; + + this.default_extension = 'PNG'; + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.key.toLowerCase(); + if (this.Helper.is_input(event.target)) + return; + + if (code == "s") { + if(event.shiftKey){ + //export + this.save(); + } + else{ + //save + this.export(); + } + event.preventDefault(); + } + }, false); + } + + /** + * saves as non destructive mode (including layers, RAW) + */ + save(){ + var types = JSON.parse(JSON.stringify(this.SAVE_TYPES)); + for(var i in types){ + if(i != 'JSON'){ + delete types[i]; + } + } + + this.save_general(types, 'Save as'); + + } + + /** + * save as encoded image + */ + export(){ + var types = JSON.parse(JSON.stringify(this.SAVE_TYPES)); + delete types.JSON; + + this.save_general(types, 'Export'); + } + + save_general(file_types, title) { + var _this = this; + + //find default format + var save_default = null; + var save_default_cookie = this.Helper.getCookie('save_default'); + + for(var i in file_types) { + if(save_default_cookie == i){ + save_default = i; + break; + } + } + if(save_default == null){ + save_default = Object.keys(file_types)[0]; + } + save_default = save_default + " - " + file_types[save_default]; + + var calc_size_value = false; + var calc_size = false; + if (config.WIDTH * config.HEIGHT < 1000000) { + calc_size_value = true; + calc_size = true; + } + + var file_name = config.layers[0].name; + var parts = file_name.split('.'); + if (parts.length > 1) + file_name = parts[parts.length - 2]; + file_name = file_name.replace(/ /g, "-"); + file_name = this.Helper.escapeHtml(file_name); + + var save_types = []; + for(var i in file_types) { + save_types.push(i + " - " + file_types[i]); + } + + var save_layers_types = [ + 'All', + 'Selected', + 'Separated', + 'Separated (original types)', + ]; + var resolution = this.Tools_settings.get_setting('resolution'); + + var settings = { + title: title, + params: [ + {name: "name", title: "File name:", value: file_name}, + {name: "type", title: "Save as type:", values: save_types, value: save_default}, + {name: "quality", title: "Quality:", value: 90, range: [1, 100]}, + {title: "File size:", html: '-'}, + {title: "Resolution:", value: resolution}, + {name: "calc_size", title: "Show file size:", value: calc_size_value}, + {name: "layers", title: "Save layers:", values: save_layers_types}, + {name: "delay", title: "Gif delay:", value: 400}, + ], + on_change: function (params, canvas_preview, w, h) { + _this.save_dialog_onchange(true); + }, + on_finish: function (params) { + // These types handle their own layering internally — skip the separated loop. + var _type = params.type ? params.type.split(' ')[0] : ''; + var _multilayerType = _type === 'TIFF_LAYERS' || _type === 'TIFF_CMYK' + || _type === 'PDF' || _type === 'PDF_CMYK'; + + if (!_multilayerType && (params.layers == 'Separated' || params.layers == 'Separated (original types)')) { + var active_layer = config.layer.id; + var original_layer_type = params.layers; + + //alter params + params.layers = 'Selected'; + + for (var i in config.layers) { + if (config.layers[i].visible == false) + continue; + + //detect type + if (original_layer_type == 'Separated (original types)') { + //detect type from file name + params.type = _this.SAVE_TYPES[_this.default_extension]; + for (var j in _this.SAVE_TYPES) { + if (_this.Helper.strpos(config.layers[i].name.toLowerCase(), '.' + j.toLowerCase()) !== false) { + params.type = j; + break; + } + } + } + + new app.Actions.Select_layer_action(config.layers[i].id, true).do(); + _this.save_action(params, true); + } + new app.Actions.Select_layer_action(active_layer, true).do(); + } + else { + _this.save_action(params); + } + }, + }; + this.POP.show(settings); + + document.getElementById("pop_data_name").select(); + + if (calc_size == true) { + //calc size once + this.save_dialog_onchange(true); + } + else{ + this.save_dialog_onchange(false); + } + } + + save_data_url() { + var max = 10 * 1000 * 1000; + if (config.WIDTH * config.WIDTH > 10 * 1000 * 1000) { + alertify.error('Size is too big, max ' + this.Helper.number_format(max, 0) + ' pixels.'); + return; + } + + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + + this.disable_canvas_smooth(ctx); + + //ask data + this.Base_layers.convert_layers_to_canvas(ctx, null, false); + var data_url = canvas.toDataURL(); + + max = 1000 * 1000; + if (data_url.length > max) { + alertify.error('Size is too big, max ' + this.Helper.number_format(max, 0) + ' bytes.'); + return; + } + + var settings = { + title: 'Data URL', + params: [ + {name: "url", title: "URL:", type: "textarea", value: data_url}, + ], + }; + this.POP.show(settings); + } + + update_file_size(file_size) { + if (typeof file_size == 'string') { + document.getElementById('file_size').innerHTML = file_size; + return; + } + + if (file_size > 1024 * 1024) + file_size = this.Helper.number_format(file_size / 1024 / 1024, 2) + ' MB'; + else if (file_size > 1024) + file_size = this.Helper.number_format(file_size / 1024, 2) + ' KB'; + else + file_size = (file_size) + ' B'; + document.getElementById('file_size').innerHTML = file_size; + } + + /** + * /activated on save dialog parameters change - used for calculating file size + * + * @param {boolean} calculate_file_size + */ + save_dialog_onchange(calculate_file_size) { + var _this = this; + var user_response = this.POP.get_params(); + + var quality = parseInt(user_response.quality); + if (quality > 100 || quality < 1 || isNaN(quality) == true) + quality = 90; + quality = quality / 100; + + //detect type + var type = user_response.type; + var parts = type.split(" "); + type = parts[0]; + + if (type == 'JPG' || type == 'WEBP') + document.getElementById('popup-tr-quality').style.display = ''; + else + document.getElementById('popup-tr-quality').style.display = 'none'; + + if (type == 'GIF') + document.getElementById('popup-tr-delay').style.display = ''; + else + document.getElementById('popup-tr-delay').style.display = 'none'; + + if (type == 'JSON' || type == 'GIF' || type == 'TIFF_LAYERS' || type == 'TIFF_CMYK' || type == 'PDF_CMYK') + document.getElementById('popup-tr-layers').style.display = 'none'; + else + document.getElementById('popup-tr-layers').style.display = ''; + + if (user_response.layers == 'Separated') + document.getElementById('pop_data_name').disabled = true; + else + document.getElementById('pop_data_name').disabled = false; + + if (user_response.layers == 'Separated (original types)') { + if(document.getElementById('popup-group-type')) { + document.getElementById('popup-group-type').style.opacity = "0.5"; + } + document.getElementById('popup-tr-quality').style.display = ''; + } + else { + if(document.getElementById('popup-group-type')) { + document.getElementById('popup-group-type').style.opacity = "1"; + } + } + + if(calculate_file_size == false){ + return; + } + + this.update_file_size('...'); + + if (user_response.calc_size == false || user_response.layers == 'Separated' + || user_response.layers == 'Separated (original types)') { + + document.getElementById('file_size').innerHTML = '-'; + return; + } + + if (type != 'JSON') { + //create temp canvas + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + this.disable_canvas_smooth(ctx); + + //ask data + if (user_response.layers == 'Selected' && type != 'GIF' && config.layer.type != null) { + //only current layer !!! + var layer = config.layer; + + var initial_x = null; + var initial_y = null; + if (layer.x != null && layer.y != null && layer.width != null && layer.height != null) { + //change position to top left corner + initial_x = layer.x; + initial_y = layer.y; + layer.x = 0; + layer.y = 0; + + canvas.width = layer.width; + canvas.height = layer.height; + } + + this.Base_layers.convert_layers_to_canvas(ctx, layer.id, false); + + if (initial_x != null) { + //restore position + layer.x = initial_x; + layer.y = initial_y; + } + } + else { + this.Base_layers.convert_layers_to_canvas(ctx, null, false); + } + } + + if (type != 'JSON' && (type == 'JPG' || config.TRANSPARENCY == false)) { + //add white background + ctx.globalCompositeOperation = 'destination-over'; + this.fillCanvasBackground(ctx, '#ffffff'); + ctx.globalCompositeOperation = 'source-over'; + } + + //calc size + if (type == 'PNG') { + //png + canvas.toBlob(function (blob) { + _this.update_file_size(blob.size); + }); + } + else if (type == 'JPG') { + //jpg + canvas.toBlob(function (blob) { + _this.update_file_size(blob.size); + }, "image/jpeg", quality); + } + else if (type == 'WEBP') { + //WEBP + var data_header = "image/webp"; + + //check support + if (this.check_format_support(canvas, data_header, false) == false) { + this.update_file_size('-'); + return; + } + + canvas.toBlob(function (blob) { + _this.update_file_size(blob.size); + }, data_header, quality); + } + else if (type == 'AVIF') { + //AVIF + var data_header = "image/avif"; + + //check support + if (this.check_format_support(canvas, data_header, false) == false) { + this.update_file_size('-'); + return; + } + + canvas.toBlob(function (blob) { + _this.update_file_size(blob.size); + }, data_header, quality); + } + else if (type == 'BMP') { + //bmp + var data_header = "image/bmp"; + + //check support + if (this.check_format_support(canvas, data_header, false) == false) { + this.update_file_size('-'); + return; + } + + canvas.toBlob(function (blob) { + _this.update_file_size(blob.size); + }, data_header); + } + else if (type == 'TIFF') { + CanvasToTIFF.toBlob(canvas, function(blob) { + _this.update_file_size(blob.size); + }, {}); + } + else if (type == 'TIFF_CMYK' || type == 'TIFF_LAYERS') { + // size estimate: W*H*4 bytes of pixel data + small overhead + _this.update_file_size(config.WIDTH * config.HEIGHT * 4 + 512); + } + else if (type == 'PDF' || type == 'PDF_CMYK') { + _this.update_file_size('-'); + } + else if (type == 'JSON') { + //json + var data_json = this.export_as_json(); + + var blob = new Blob([data_json], {type: "text/plain"}); + this.update_file_size(blob.size); + } + else if (type == 'GIF') { + //gif + this.update_file_size('-'); + } + } + + /** + * saves data in requested way + * + * @param {object} user_response parameters + * @param {boolean} autoname if use name from layer, false by default + */ + save_action(user_response, autoname) { + var fname = user_response.name; + if(autoname === true && user_response.layers == 'Selected'){ + fname = config.layer.name; + } + + var quality = parseInt(user_response.quality); + if (quality > 100 || quality < 1 || isNaN(quality) == true) + quality = 90; + quality = quality / 100; + + var delay = parseInt(user_response.delay); + if (delay < 0 || isNaN(delay) == true) + delay = 400; + + //detect type + var type = user_response.type; + var parts = type.split(" "); + type = parts[0]; + + //detect type from file name + for(var i in this.SAVE_TYPES) { + if (this.Helper.strpos(fname, '.' + i.toLowerCase()) !== false) { + type = i; + } + } + + //save default type as cookie + if(this.Helper.getCookie('save_default') == '' || this.Helper.getCookie('save_default') != type){ + this.Helper.setCookie('save_default', type); + } + + if (type != 'JSON') { + //temp canvas + var canvas; + var ctx; + + //get data + if (user_response.layers == 'Selected' && type != 'GIF') { + canvas = this.Base_layers.convert_layer_to_canvas(); + ctx = canvas.getContext("2d"); + } + else { + canvas = document.createElement('canvas'); + ctx = canvas.getContext("2d"); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + this.disable_canvas_smooth(ctx); + + this.Base_layers.convert_layers_to_canvas(ctx, null, false); + } + } + + // CMYK and JPG need an opaque white background (no alpha channel in output) + if (type != 'JSON' && (type == 'JPG' || type == 'TIFF_CMYK' || config.TRANSPARENCY == false)) { + ctx.globalCompositeOperation = 'destination-over'; + this.fillCanvasBackground(ctx, '#ffffff'); + ctx.globalCompositeOperation = 'source-over'; + } + + if (type == 'PNG') { + //png - default format + if (this.Helper.strpos(fname, '.png') == false) + fname = fname + ".png"; + + //simple save example + //var link = document.createElement('a'); + //link.download = fname; + //link.href = canvas.toDataURL(); + //link.click(); + + //save using lib + canvas.toBlob(function (blob) { + filesaver.saveAs(blob, fname); + }); + } + else if (type == 'JPG') { + //jpg + if (this.Helper.strpos(fname, '.jpg') == false) + fname = fname + ".jpg"; + + canvas.toBlob(function (blob) { + filesaver.saveAs(blob, fname); + }, "image/jpeg", quality); + } + else if (type == 'WEBP') { + //WEBP + if (this.Helper.strpos(fname, '.webp') == false) + fname = fname + ".webp"; + var data_header = "image/webp"; + + //check support + if (this.check_format_support(canvas, data_header) == false) + return false; + + canvas.toBlob(function (blob) { + filesaver.saveAs(blob, fname); + }, data_header, quality); + } + else if (type == 'AVIF') { + //AVIF + if (this.Helper.strpos(fname, '.avif') == false) + fname = fname + ".avif"; + var data_header = "image/avif"; + + //check support + if (this.check_format_support(canvas, data_header) == false) + return false; + + canvas.toBlob(function (blob) { + filesaver.saveAs(blob, fname); + }, data_header, quality); + } + else if (type == 'BMP') { + //bmp + if (this.Helper.strpos(fname, '.bmp') == false) + fname = fname + ".bmp"; + var data_header = "image/bmp"; + + //check support + if (this.check_format_support(canvas, data_header) == false) + return false; + + canvas.toBlob(function (blob) { + filesaver.saveAs(blob, fname); + }, data_header); + } + else if (type == 'TIFF') { + //tiff - single page RGBA (existing behaviour) + if (this.Helper.strpos(fname, '.tiff') == false) + fname = fname + ".tiff"; + + CanvasToTIFF.toBlob(canvas, function(blob) { + filesaver.saveAs(blob, fname); + }, {}); + } + else if (type == 'TIFF_CMYK') { + //tiff - single page CMYK, print-ready + if (this.Helper.strpos(fname, '.tiff') == false) + fname = fname + ".tiff"; + var resolution = this.Tools_settings.get_setting('resolution') || 300; + + TiffWriter.toCMYK(canvas, function(buf) { + filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname); + }, {dpi: resolution}); + } + else if (type == 'TIFF_LAYERS') { + //tiff - multipage: one IFD per visible layer + if (this.Helper.strpos(fname, '.tiff') == false) + fname = fname + ".tiff"; + var resolution = this.Tools_settings.get_setting('resolution') || 300; + var layerCanvases = this._collect_layer_canvases(); + + TiffWriter.toMultipageRGBA(layerCanvases, function(buf) { + filesaver.saveAs(new Blob([buf], {type: 'image/tiff'}), fname); + }, {dpi: resolution}); + } + else if (type == 'PDF') { + //pdf - RGB, one page per visible layer + if (this.Helper.strpos(fname, '.pdf') == false) + fname = fname + ".pdf"; + var resolution = this.Tools_settings.get_setting('resolution') || 300; + var quality_val = quality; + + var pdfCanvases; + if (user_response.layers == 'Selected') { + pdfCanvases = [canvas]; + } else { + pdfCanvases = this._collect_layer_canvases(); + } + + PdfWriter.fromCanvases(pdfCanvases, {colorMode: 'rgb', quality: quality_val, dpi: resolution}) + .then(function(blob) { filesaver.saveAs(blob, fname); }); + } + else if (type == 'PDF_CMYK') { + //pdf - CMYK, one page per visible layer, print-ready + if (this.Helper.strpos(fname, '.pdf') == false) + fname = fname + ".pdf"; + var resolution = this.Tools_settings.get_setting('resolution') || 300; + var layerCanvases = this._collect_layer_canvases(); + + PdfWriter.fromCanvases(layerCanvases, {colorMode: 'cmyk', dpi: resolution}) + .then(function(blob) { filesaver.saveAs(blob, fname); }); + } + else if (type == 'JSON') { + //json - full data with layers + if (this.Helper.strpos(fname, '.json') == false) + fname = fname + ".json"; + + var data_json = this.export_as_json(); + + var blob = new Blob([data_json], {type: "text/plain"}); + //var data = window.URL.createObjectURL(blob); //html5 + filesaver.saveAs(blob, fname); + } + else if (type == 'GIF') { + //gif + var cores = navigator.hardwareConcurrency || 4; + var gif_settings = { + workers: cores, + quality: 10, //1-30, lower is better + repeat: 0, + width: config.WIDTH, + height: config.HEIGHT, + dither: 'FloydSteinberg-serpentine', + workerScript: './src/js/libs/gifjs/gif.worker.js', + }; + if (config.TRANSPARENCY == true) { + gif_settings.transparent = 'rgba(0,0,0,0)'; + } + var gif = new GIF(gif_settings); + + //add frames + for (var i = 0; i < config.layers.length; i++) { + if (config.layers[i].visible == false) + continue; + + ctx.clearRect(0, 0, config.WIDTH, config.HEIGHT); + if (config.TRANSPARENCY == false) { + this.fillCanvasBackground(ctx, '#ffffff'); + } + this.Base_layers.convert_layers_to_canvas(ctx, config.layers[i].id, false); + + gif.addFrame(ctx, {copy: true, delay: delay}); + } + gif.render(); + gif.on('finished', function (blob) { + filesaver.saveAs(blob, fname); + }); + } + } + + fillCanvasBackground(ctx, color, width = config.WIDTH, height = config.HEIGHT) { + ctx.beginPath(); + ctx.rect(0, 0, width, height); + ctx.fillStyle = color; + ctx.fill(); + } + + check_format_support(canvas, data_header, show_error) { + var data = canvas.toDataURL(data_header); + var actualType = data.replace(/^data:([^;]*).*/, '$1'); + + if (data_header != actualType && data_header != "text/plain") { + if (show_error == undefined || show_error == true) { + //error - no support + alertify.error('Your browser does not support this format.'); + } + return false; + } + return true; + } + + /** + * exports all layers to JSON + */ + export_as_json() { + //get date + var today = new Date(); + var yyyy = today.getFullYear(); + var mm = today.getMonth() + 1; //January is 0! + var dd = today.getDate(); + if (dd < 10) + dd = '0' + dd; + if (mm < 10) + mm = '0' + mm; + var today = yyyy + '-' + mm + '-' + dd; + + //data + var export_data = {}; + export_data.info = { + width: config.WIDTH, + height: config.HEIGHT, + about: 'Image data with multi-layers. Can be opened using miniPaint - ' + + 'https://github.com/viliusle/miniPaint', + date: today, + version: VERSION, + layer_active: config.layer.id, + guides: config.guides, + }; + + //fonts + export_data.user_fonts = config.user_fonts; + + //layers + export_data.layers = []; + for (var i in config.layers) { + var layer = {}; + for (var j in config.layers[i]) { + if (j[0] == '_' || j == 'link_canvas') { + //private data + continue; + } + + layer[j] = config.layers[i][j]; + } + export_data.layers.push(layer); + } + + //image data + export_data.data = []; + for (var i in config.layers) { + if (config.layers[i].type != 'image') + continue; + + var canvas = document.createElement('canvas'); + canvas.width = config.layers[i].width_original; + canvas.height = config.layers[i].height_original; + this.disable_canvas_smooth(canvas.getContext("2d")); + + canvas.getContext('2d').drawImage(config.layers[i].link, 0, 0); + + var data_tmp = canvas.toDataURL("image/png"); + export_data.data.push( + { + id: config.layers[i].id, + data: data_tmp, + } + ); + canvas.width = 1; + canvas.height = 1; + } + + return JSON.stringify(export_data, null, "\t"); + } + + /** + * Returns one canvas per visible layer, each composited individually. + * Used for multilayer TIFF and multipage PDF export. + */ + _collect_layer_canvases() { + var canvases = []; + for (var i = 0; i < config.layers.length; i++) { + if (config.layers[i].visible == false) continue; + var c = document.createElement('canvas'); + var cx = c.getContext('2d'); + c.width = config.WIDTH; + c.height = config.HEIGHT; + this.disable_canvas_smooth(cx); + this.Base_layers.convert_layers_to_canvas(cx, config.layers[i].id, false); + canvases.push(c); + } + // Fall back to full composite if no layers found + if (canvases.length === 0) { + var c = document.createElement('canvas'); + var cx = c.getContext('2d'); + c.width = config.WIDTH; + c.height = config.HEIGHT; + this.disable_canvas_smooth(cx); + this.Base_layers.convert_layers_to_canvas(cx, null, false); + canvases.push(c); + } + return canvases; + } + + /** + * removes smoothing, because it look ugly during zoom + * + * @param {ctx} ctx + */ + disable_canvas_smooth(ctx) { + ctx.webkitImageSmoothingEnabled = false; + ctx.oImageSmoothingEnabled = false; + ctx.msImageSmoothingEnabled = false; + ctx.imageSmoothingEnabled = false; + } + +} + +export default File_save_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/generate/outpaint.js b/paintplus/frontend/src/js/modules/generate/outpaint.js new file mode 100644 index 0000000..cd7165a --- /dev/null +++ b/paintplus/frontend/src/js/modules/generate/outpaint.js @@ -0,0 +1,142 @@ +/** + * Outpaint / Expand Canvas — remote provider fills the new region. + * Menu target: generate/outpaint.outpaint + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../../services/api.js'; +import { getCapabilities } from './../../api/capabilities.js'; + +var instance = null; + +class Generate_outpaint_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async outpaint() { + var caps = await getCapabilities(); + if (!caps.remote || !caps.remote.healthy) { + alertify.error( + 'Expand Canvas requires a remote AI provider. ' + + 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + ); + return; + } + + var _this = this; + + this.Dialog.show({ + title: 'Expand Canvas (Outpaint)', + params: [ + { + name: 'direction', + title: 'Expand direction:', + value: 'right', + values: ['right', 'left', 'bottom', 'top'], + }, + { + name: 'size', + title: 'Pixels to add:', + type: 'range', + value: 256, + range: [64, 1024], + step: 64, + }, + { + name: 'prompt', + title: 'Describe the expansion (optional):', + value: '', + placeholder: "e.g. 'continue the landscape', 'more sky and clouds'", + }, + ], + on_finish: async function (params) { + await _this._run(params); + }, + }); + } + + async _run(params) { + if (this.isProcessing) return; + if (config.layer.type !== 'image') { + alertify.error('Current layer must be an image.'); + return; + } + + this.isProcessing = true; + alertify.message('Expanding canvas... please wait', 0); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + + var response = await fetch( + (window.API_BASE_URL || '') + '/api/generate/outpaint', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + direction: params.direction, + size: params.size || 256, + prompt: params.prompt || '', + }), + } + ); + if (!response.ok) { + var err = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(err.detail || 'Outpaint failed'); + } + var result = await response.json(); + + var img = new Image(); + img.onload = () => { + var newW = img.naturalWidth; + var newH = img.naturalHeight; + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = newW; + resultCanvas.height = newH; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + // Update canvas dimensions and replace layer + config.WIDTH = newW; + config.HEIGHT = newH; + app.State.do_action( + new app.Actions.Bundle_action('outpaint', 'Expand Canvas', [ + new app.Actions.Resize_canvas_action(newW, newH), + new app.Actions.Update_layer_image_action(resultCanvas), + ]) + ); + + alertify.dismissAll(); + alertify.success('Canvas expanded!'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load expanded image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Outpaint failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Generate_outpaint_class; diff --git a/paintplus/frontend/src/js/modules/generate/text_to_image.js b/paintplus/frontend/src/js/modules/generate/text_to_image.js new file mode 100644 index 0000000..82b5aa4 --- /dev/null +++ b/paintplus/frontend/src/js/modules/generate/text_to_image.js @@ -0,0 +1,216 @@ +/** + * Text → Image — generates via remote or local-GPU provider, + * pastes result as a new layer on the current canvas. + * + * Menu target: generate/text_to_image.text_to_image + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../../services/api.js'; +import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../../libs/progress_overlay.js'; + +var instance = null; + +class Generate_text_to_image_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async text_to_image() { + var caps = await getCapabilities(); + var hasRemote = caps.remote && caps.remote.healthy; + var hasLocal = caps.local && caps.local.local_gpu_available; + + if (!hasRemote && !hasLocal) { + alertify.error( + 'Text → Image requires an AI provider. ' + + 'Set AI_PROVIDER=openai / invokeai / comfyui / local_gpu in .env and restart, ' + + 'or configure one in Image → AI Provider Settings.' + ); + return; + } + + var _this = this; + var canvasW = config.WIDTH || 1024; + var canvasH = config.HEIGHT || 1024; + + // Build provider info line + var providerHtml = hasRemote + ? `● ${caps.remote.provider}` + : `● local GPU · ${caps.local.gpu_tier || ''} · ${_shortGpu(caps.local.gpu_device)}`; + + // Model note for local GPU + var modelNote = ''; + if (hasLocal && !hasRemote) { + var rec = caps.local.local_gpu_capabilities && caps.local.local_gpu_capabilities.recommended; + var m = rec && rec.txt2img; + if (m) { + modelNote = `Model: ${m.model_id.split('/').pop()}`; + if (m.memory_opt && m.memory_opt !== 'none') modelNote += ` · ${m.memory_opt}`; + } + } + + // Estimate generation time (rough guide for the progress bar) + var estSec = hasLocal ? 60 : 15; // local GPU ~1 min; OpenAI ~15s + + var defaultW = Math.min(canvasW, hasLocal ? (caps.local.local_gpu_capabilities?.recommended?.txt2img?.native_res || 1024) : 1024); + var defaultH = Math.min(canvasH, defaultW); + + this.Dialog.show({ + title: 'Text → Image', + params: [ + { + title: '', + html: `
    + Provider: ${providerHtml}${modelNote ? ' · ' + modelNote : ''}
    + Generation typically takes ${estSec < 30 ? 'a few seconds' : estSec < 90 ? '30–90 seconds on local GPU' : '1–3 minutes on local GPU'}. +
    `, + }, + { + name: 'prompt', + title: 'Describe your image:', + type: 'textarea', + value: '', + placeholder: "e.g. 'a serene mountain lake at sunset, cinematic lighting'", + }, + { + name: 'negative_prompt', + title: 'Avoid (optional):', + value: '', + placeholder: 'blurry, distorted, watermark', + }, + { + name: 'width', + title: 'Width (px):', + value: defaultW, + range: [256, 2048], + step: 64, + type: 'range', + }, + { + name: 'height', + title: 'Height (px):', + value: defaultH, + range: [256, 2048], + step: 64, + type: 'range', + }, + { + name: 'placement', + title: 'Add as:', + value: 'new_layer', + values: ['new_layer', 'replace_canvas'], + }, + { + name: 'steps', + title: 'Steps:', + type: 'range', + value: 30, + range: [10, 60], + step: 5, + }, + { + name: 'seed', + title: 'Seed (0 = random):', + value: 0, + range: [0, 2147483647], + step: 1, + type: 'range', + }, + ], + on_finish: async function (params) { + if (!params.prompt || !params.prompt.trim()) { + alertify.warning('Please enter a description.'); + return; + } + await _this._generate(params, estSec); + }, + }); + } + + async _generate(params, estSec) { + if (this.isProcessing) return; + this.isProcessing = true; + + connectProgressSSE('txt2img', window.API_BASE_URL || ''); + showProgress('Generating image…', estSec || 60); + + try { + var result = await apiService.textToImage(params.prompt, { + width: params.width || 1024, + height: params.height || 1024, + negativePrompt: params.negative_prompt || '', + steps: params.steps || 30, + seed: params.seed || 0, + }); + + updateProgress(95, 'Placing image…'); + + var img = new Image(); + img.onload = () => { + if (params.placement === 'replace_canvas') { + config.WIDTH = img.naturalWidth; + config.HEIGHT = img.naturalHeight; + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + app.State.do_action( + new app.Actions.Bundle_action('txt2img_replace', 'Text → Image', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [ + new app.Actions.Insert_layer_action({ + name: params.prompt.slice(0, 30), + type: 'image', + data: img.src, + x: 0, y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + width_original: img.naturalWidth, + height_original: img.naturalHeight, + }) + ]) + ); + } + disconnectProgressSSE(); + hideProgress(); + alertify.success('Image generated!'); + this.isProcessing = false; + }; + img.onerror = () => { + disconnectProgressSSE(); + hideProgress(); + alertify.error('Failed to load generated image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + disconnectProgressSSE(); + hideProgress(); + alertify.error('Generation failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +function _shortGpu(name) { + if (!name) return 'GPU'; + return name.replace(/^NVIDIA GeForce /i, '').replace(/^NVIDIA /i, ''); +} + +export default Generate_text_to_image_class; diff --git a/paintplus/frontend/src/js/modules/help/about.js b/paintplus/frontend/src/js/modules/help/about.js new file mode 100644 index 0000000..405f1f3 --- /dev/null +++ b/paintplus/frontend/src/js/modules/help/about.js @@ -0,0 +1,36 @@ +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; + +class Help_about_class { + + constructor() { + this.POP = new Dialog_class(); + } + + //about + about() { + var email = 'www.viliusl@gmail.com'; + + var settings = { + title: 'About', + params: [ + {title: "", html: ''}, + {title: "Name:", html: 'PaintPlus'}, + {title: "Version:", value: VERSION}, + {title: "Description:", value: "Layer-based image editor with AI tools."}, + {title: "", html: '
    '}, + {title: "Base:", html: 'miniPaint by ViliusL'}, + {title: "AI Erase:", html: 'LaMa (Samsung Research) via simple-lama-inpainting'}, + {title: "Bg Removal:", html: 'rembg / U2Net / OpenCV'}, + {title: "Smart Select:", html: 'SAM (Meta AI)'}, + {title: "Remote AI:", html: 'InvokeAI · ComfyUI · OpenAI (user-configured)'}, + {title: "", html: '
    '}, + {title: "GitHub:", html: 'outis1one/EditmaskwithAI'}, + ], + }; + this.POP.show(settings); + } + +} + +export default Help_about_class; diff --git a/paintplus/frontend/src/js/modules/help/shortcuts.js b/paintplus/frontend/src/js/modules/help/shortcuts.js new file mode 100644 index 0000000..0c05a62 --- /dev/null +++ b/paintplus/frontend/src/js/modules/help/shortcuts.js @@ -0,0 +1,44 @@ +import Dialog_class from './../../libs/popup.js'; + +class Help_shortcuts_class { + + constructor() { + this.POP = new Dialog_class(); + } + + //shortcuts + shortcuts() { + var settings = { + title: 'Keyboard Shortcuts', + className: 'shortcuts', + params: [ + {title: "F", value: 'Auto Adjust Colors'}, + {title: "F3 / ⌘ + F", value: 'Search'}, + {title: "Ctrl + C", value: 'Copy to Clipboard'}, + {title: "D", value: 'Duplicate'}, + {title: "S", value: 'Export'}, + {title: "G", value: 'Grid on/off'}, + {title: "I", value: 'Information'}, + {title: "N", value: 'New layer'}, + {title: "O", value: 'Open'}, + {title: "CTRL + V", value: 'Paste'}, + {title: "F10", value: 'Quick Load'}, + {title: "F9", value: 'Quick Save'}, + {title: "R", value: 'Resize'}, + {title: "L", value: 'Rotate left'}, + {title: "U", value: 'Ruler'}, + {title: "Shift + S", value: 'Save As'}, + {title: "CTRL + A", value: 'Select All'}, + {title: "H", value: 'Shapes'}, + {title: "T", value: 'Trim'}, + {title: "CTRL + Z", value: 'Undo'}, + {title: "Scroll up", value: 'Zoom in'}, + {title: "Scroll down", value: 'Zoom out'}, + ], + }; + this.POP.show(settings); + } + +} + +export default Help_shortcuts_class; diff --git a/paintplus/frontend/src/js/modules/image/auto_adjust.js b/paintplus/frontend/src/js/modules/image/auto_adjust.js new file mode 100644 index 0000000..4bda199 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/auto_adjust.js @@ -0,0 +1,168 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Image_autoAdjust_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 70 && event.ctrlKey != true && event.metaKey != true) { + //F - adjust + this.auto_adjust(); + event.preventDefault(); + } + }, false); + } + + auto_adjust() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.get_adjust_data(img); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + get_adjust_data(data) { + //settings + var white = 240; //white color min + var black = 30; //black color max + var target_white = 1; //how much % white colors should take + var target_black = 0.5; //how much % black colors should take + var modify = 1.1; //color modify strength + var cycles_count = 10; //how much iteration to change colors + + var imgData = data.data; + var W = data.width; + var H = data.height; + + var n = 0; //pixels count without transparent + + //make sure we have white + var n_valid = 0; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 > white) + n_valid++; + n++; + } + var target = target_white; + var n_fix_white = 0; + var done = false; + for (var j = 0; j < cycles_count; j++) { + if (n_valid * 100 / n >= target) + done = true; + if (done == true) + break; + n_fix_white++; + + //adjust + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + for (var c = 0; c < 3; c++) { + var x = i + c; + if (imgData[x] < 10) + continue; + //increase white + imgData[x] *= modify; + imgData[x] = Math.round(imgData[x]); + if (imgData[x] > 255) + imgData[x] = 255; + } + } + + //recheck + n_valid = 0; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 > white) + n_valid++; + } + } + + //make sure we have black + n_valid = 0; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 < black) + n_valid++; + } + target = target_black; + var n_fix_black = 0; + var done = false; + for (var j = 0; j < cycles_count; j++) { + if (n_valid * 100 / n >= target) + done = true; + if (done == true) + break; + n_fix_black++; + + //adjust + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + for (var c = 0; c < 3; c++) { + var x = i + c; + if (imgData[x] > 240) + continue; + //increase black + imgData[x] -= (255 - imgData[x]) * modify - (255 - imgData[x]); + imgData[x] = Math.round(imgData[x]); + } + } + + //recheck + n_valid = 0; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + if ((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3 < black) + n_valid++; + } + } + //log('Iterations: brighten='+n_fix_white+", darken="+n_fix_black); + + return data; + } +} + +export default Image_autoAdjust_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/auto_enhance.js b/paintplus/frontend/src/js/modules/image/auto_enhance.js new file mode 100644 index 0000000..e8bd64d --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/auto_enhance.js @@ -0,0 +1,124 @@ +/** + * Auto-Enhance — one-click smart photo improvement. + * Applies auto white balance, CLAHE contrast, saturation boost, and mild sharpening. + * Strength slider lets the user dial in how strong the effect is. + * + * Menu target: image/auto_enhance.auto_enhance + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Image_auto_enhance_class { + constructor() { + if (instance) return instance; + instance = this; + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async auto_enhance() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + var _this = this; + this.Dialog.show({ + title: 'Auto-Enhance', + params: [ + { + title: '', + html: `
    + Automatically improves white balance, contrast, saturation, and sharpness. +
    `, + }, + { + name: 'strength', + title: 'Strength:', + value: '100', + values: ['25', '50', '75', '100'], + type: 'select', + }, + { + name: 'new_layer', + title: 'Keep original as separate layer:', + value: false, + }, + ], + on_finish: async function (params) { + await _this._run(parseFloat(params.strength) / 100, params.new_layer); + }, + }); + } + + async _run(strength, newLayer) { + if (this.isProcessing) return; + this.isProcessing = true; + alertify.message('Enhancing…', 0); + + try { + const layer = config.layer; + const c = document.createElement('canvas'); + c.width = layer.width_original; c.height = layer.height_original; + c.getContext('2d').drawImage(layer.link, 0, 0); + const imageB64 = c.toDataURL('image/png').split(',')[1]; + + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/enhance`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageB64, strength }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed'); + const data = await r.json(); + + const img = new Image(); + img.onload = () => { + const rc = document.createElement('canvas'); + rc.width = img.naturalWidth; rc.height = img.naturalHeight; + rc.getContext('2d').drawImage(img, 0, 0); + + if (newLayer) { + app.State.do_action( + new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [ + new app.Actions.Insert_layer_action({ + name: layer.name + ' (Enhanced)', + type: 'image', + data: img.src, + x: layer.x, y: layer.y, + width: img.naturalWidth, height: img.naturalHeight, + width_original: img.naturalWidth, height_original: img.naturalHeight, + }) + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('auto_enhance', 'Auto-Enhance', [ + new app.Actions.Update_layer_image_action(rc) + ]) + ); + } + alertify.dismissAll(); + alertify.success('Enhancement applied.'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + data.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Auto-enhance failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Image_auto_enhance_class; diff --git a/paintplus/frontend/src/js/modules/image/color_corrections.js b/paintplus/frontend/src/js/modules/image/color_corrections.js new file mode 100644 index 0000000..79de61c --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/color_corrections.js @@ -0,0 +1,134 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import ImageFilters_class from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Image_colorCorrections_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ImageFilters = ImageFilters_class; + } + + color_corrections() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Color Corrections', + preview: true, + on_change: function (params, canvas_preview, w, h, canvas) { + //destructive effects + var img = this.layer_active_small_ctx.getImageData(0, 0, w, h); + var data = _this.do_corrections(img, params, false); + canvas_preview.putImageData(data, 0, 0); + + //non-destructive + canvas_preview.filter = "brightness(" + (1 + (params.param_b / 100)) + ")"; + canvas_preview.filter += " contrast(" + (1 + (params.param_c / 100)) + ")"; + canvas_preview.filter += " saturate(" + (1 + (params.param_s / 100)) + ")"; + canvas_preview.filter += " hue-rotate(" + params.param_h + "deg)"; + + canvas_preview.drawImage(canvas, 0, 0); + }, + params: [ + {name: "param_b", title: "Brightness:", value: "0", range: [-100, 100]}, + {name: "param_c", title: "Contrast:", value: "0", range: [-100, 100]}, + {name: "param_s", title: "Saturation:", value: "0", range: [-100, 100]}, + {name: "param_h", title: "Hue:", value: "0", range: [-180, 180]}, + {}, + {name: "param_l", title: "Luminance:", value: "0", range: [-100, 100]}, + {}, + {name: "param_red", title: "Red channel:", value: "0", range: [-255, 255]}, + {name: "param_green", title: "Green channel:", value: "0", range: [-255, 255]}, + {name: "param_blue", title: "Blue channel:", value: "0", range: [-255, 255]}, + ], + on_finish: function (params) { + _this.save_changes(params); + }, + }; + this.POP.show(settings); + } + + save_changes(params) { + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.do_corrections(img, params); + ctx.putImageData(data, 0, 0); + + //save + app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + + //non-destructive filters + //multiple do_action() + do_corrections() does not work together yet. + if(params.param_b != 0) { + var parameters = {value: params.param_b}; + var filter_id = null; + app.State.do_action( + new app.Actions.Add_layer_filter_action(null, 'brightness', parameters, filter_id) + ); + } + if(params.param_c != 0) { + var parameters = {value: params.param_c}; + var filter_id = null; + app.State.do_action( + new app.Actions.Add_layer_filter_action(null, 'contrast', parameters, filter_id) + ); + } + if(params.param_s != 0) { + var parameters = {value: params.param_s}; + var filter_id = null; + app.State.do_action( + new app.Actions.Add_layer_filter_action(null, 'saturate', parameters, filter_id) + ); + } + if(params.param_h != 0) { + var parameters = {value: params.param_h}; + var filter_id = null; + app.State.do_action( + new app.Actions.Add_layer_filter_action(null, 'hue-rotate', parameters, filter_id) + ); + } + } + + /** + * corrections (destructive) + * + * @param data + * @param params + * @returns {*} + */ + do_corrections(data, params) { + //luminance + if(params.param_l != 0) { + var data = this.ImageFilters.HSLAdjustment(data, 0, 0, params.param_l); + } + + //RGB corrections + if(params.param_red != 0 || params.param_green != 0 || params.param_blue != 0) { + var data = this.ImageFilters.ColorTransformFilter(data, 1, 1, 1, 1, + params.param_red, params.param_green, params.param_blue, 1); + } + + return data; + } + +} + +export default Image_colorCorrections_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/color_palette.js b/paintplus/frontend/src/js/modules/image/color_palette.js new file mode 100644 index 0000000..34b13d1 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/color_palette.js @@ -0,0 +1,114 @@ +/** + * Color Palette Extractor — pull dominant colors from the current image layer. + * Shows a floating swatch panel; click a swatch to copy the hex or set as active color. + * + * Menu target: image/color_palette.color_palette + */ + +import config from './../../config.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Image_color_palette_class { + constructor() { + if (instance) return instance; + instance = this; + this._panel = null; + } + + async color_palette() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + // Toggle: if panel already showing, close it + if (this._panel) { this._removePanel(); return; } + + alertify.message('Extracting colors…', 0); + try { + const layer = config.layer; + const c = document.createElement('canvas'); + c.width = layer.width_original; c.height = layer.height_original; + c.getContext('2d').drawImage(layer.link, 0, 0); + const imageB64 = c.toDataURL('image/png').split(',')[1]; + + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/extract-colors`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageB64, count: 8 }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed'); + const data = await r.json(); + + alertify.dismissAll(); + this._showPanel(data.colors); + } catch (err) { + alertify.dismissAll(); + alertify.error('Color extraction failed: ' + (err.message || err)); + } + } + + _showPanel(colors) { + this._removePanel(); + const panel = document.createElement('div'); + panel.id = 'color_palette_panel'; + Object.assign(panel.style, { + position: 'fixed', bottom: '72px', right: '24px', + background: '#1a1a1a', border: '1px solid #3a3a3a', + borderRadius: '12px', padding: '12px 14px', + zIndex: '9998', boxShadow: '0 6px 24px rgba(0,0,0,0.6)', + fontFamily: 'sans-serif', fontSize: '12px', color: '#bbb', + userSelect: 'none', minWidth: '180px', + }); + + const swatchesHtml = colors.map(hex => ` +
    +
    `).join(''); + + panel.innerHTML = ` +
    + Image Palette + × +
    +
    ${swatchesHtml}
    +
    +
    Click: copy hex · Shift+click: set color
    `; + + document.body.appendChild(panel); + this._panel = panel; + + // Close button + panel.querySelector('#cp-close').addEventListener('click', () => this._removePanel()); + + // Swatch clicks + panel.querySelectorAll('[data-hex]').forEach(el => { + el.addEventListener('click', e => { + const hex = el.dataset.hex; + if (e.shiftKey) { + // Set as active color in miniPaint + config.COLOR = hex; + const copiedEl = panel.querySelector('#cp-copied'); + if (copiedEl) copiedEl.textContent = `Active color set to ${hex}`; + } else { + navigator.clipboard.writeText(hex).catch(() => {}); + const copiedEl = panel.querySelector('#cp-copied'); + if (copiedEl) { copiedEl.textContent = `Copied ${hex}`; } + } + }); + }); + } + + _removePanel() { + if (this._panel) { this._panel.remove(); this._panel = null; } + } +} + +export default Image_color_palette_class; diff --git a/paintplus/frontend/src/js/modules/image/decrease_colors.js b/paintplus/frontend/src/js/modules/image/decrease_colors.js new file mode 100644 index 0000000..d81fb7a --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/decrease_colors.js @@ -0,0 +1,174 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import ImageFilters_class from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Image_decreaseColors_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ImageFilters = ImageFilters_class; + } + + decrease_colors() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Decrease Color Depth', + preview: true, + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.get_decreased_data(img, params.colors, params.greyscale); + canvas_preview.putImageData(data, 0, 0); + }, + params: [ + {name: "colors", title: "Colors:", value: 10, range: [1, 256]}, + {name: "greyscale", title: "Greyscale:", value: false}, + ], + on_finish: function (params) { + _this.execute(params); + }, + }; + this.POP.show(settings); + } + + execute(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.get_decreased_data(img, params.colors, params.greyscale); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + get_decreased_data(data, colors, greyscale) { + var img = data.data; + var imgData = data.data; + var W = data.width; + var H = data.height; + var palette = []; + var block_size = 10; + + //create tmp canvas + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = W; + canvas.height = H; + + //collect top colors + ctx.drawImage(config.layer.link, 0, 0, Math.ceil(W / block_size), Math.ceil(H / block_size)); + var img_p = ctx.getImageData(0, 0, Math.ceil(W / block_size), Math.ceil(H / block_size)); + var imgData_p = img_p.data; + ctx.clearRect(0, 0, W, H); + + for (var i = 0; i < imgData_p.length; i += 4) { + if (imgData_p[i + 3] == 0) + continue; //transparent + var grey = Math.round(0.2126 * imgData_p[i] + 0.7152 * imgData_p[i + 1] + + 0.0722 * imgData_p[i + 2]); + palette.push([imgData_p[i], imgData_p[i + 1], imgData_p[i + 2], grey]); + } + + //calculate weights + var grey_palette = []; + for (var i = 0; i < 256; i++) + grey_palette[i] = 0; + for (var i = 0; i < palette.length; i++) + grey_palette[palette[i][3]]++; + + //remove similar colors + for (var max = 10 * 3; max < 100 * 3; max = max + 10 * 3) { + if (palette.length <= colors) + break; + for (var i = 0; i < palette.length; i++) { + if (palette.length <= colors) + break; + var valid = true; + for (var j = 0; j < palette.length; j++) { + if (palette.length <= colors) + break; + if (i == j) + continue; + if (Math.abs(palette[i][0] - palette[j][0]) + + Math.abs(palette[i][1] - palette[j][1]) + + Math.abs(palette[i][2] - palette[j][2]) < max) { + if (grey_palette[palette[i][3]] > grey_palette[palette[j][3]]) { + //remove color + palette.splice(j, 1); + j--; + } + else { + valid = false; + break; + } + } + } + //remove color + if (valid == false) { + palette.splice(i, 1); + i--; + } + } + } + palette = palette.slice(0, colors); + + //change + var p_n = palette.length; + for (var j = 0; j < H; j++) { + for (var i = 0; i < W; i++) { + var k = ((j * (W * 4)) + (i * 4)); + if (imgData[k + 3] == 0) + continue; //transparent + + //find closest color + var index1 = 0; + var min = 999999; + var diff1; + for (var m = 0; m < p_n; m++) { + var diff = Math.abs(palette[m][0] - imgData[k]) + + Math.abs(palette[m][1] - imgData[k + 1]) + + Math.abs(palette[m][2] - imgData[k + 2]); + if (diff < min) { + min = diff; + index1 = m; + diff1 = diff; + } + } + + imgData[k] = palette[index1][0]; + imgData[k + 1] = palette[index1][1]; + imgData[k + 2] = palette[index1][2]; + + if (greyscale == true) { + var mid = Math.round(0.2126 * imgData[k] + 0.7152 * imgData[k + 1] + + 0.0722 * imgData[k + 2]); + imgData[k] = mid; + imgData[k + 1] = mid; + imgData[k + 2] = mid; + } + } + } + + return data; + } + +} + +export default Image_decreaseColors_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/flip.js b/paintplus/frontend/src/js/modules/image/flip.js new file mode 100644 index 0000000..3fa7b34 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/flip.js @@ -0,0 +1,56 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Image_flip_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + vertical() { + this.flip('vertical'); + } + + horizontal() { + this.flip('horizontal'); + } + + flip(mode) { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //create destination canvas + var canvas2 = document.createElement('canvas'); + canvas2.width = canvas.width; + canvas2.height = canvas.height; + var ctx2 = canvas2.getContext("2d"); + canvas2.dataset.x = canvas.dataset.x; + canvas2.dataset.y = canvas.dataset.y; + + //flip + if (mode == 'vertical') { + ctx2.scale(1, -1); + ctx2.drawImage(canvas, 0, canvas2.height * -1); + } + else if (mode == 'horizontal') { + ctx2.scale(-1, 1); + ctx2.drawImage(canvas, canvas2.width * -1, 0); + } + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas2) + ); + } + +} + +export default Image_flip_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/frame_fit.js b/paintplus/frontend/src/js/modules/image/frame_fit.js new file mode 100644 index 0000000..b56bc00 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/frame_fit.js @@ -0,0 +1,229 @@ +/** + * Fit to Frame — resize/extend/crop image to a standard print frame size. + * + * Modes: + * crop — center-crop to aspect ratio, scale to print resolution (no AI needed) + * extend — scale to fill one dimension, AI-outpaint the gap (needs provider) + * smart — auto-pick: extend if gap < 15% of dimension, else crop + * + * Menu target: image/frame_fit.frame_fit + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, hideProgress } from './../../libs/progress_overlay.js'; + +var instance = null; + +const FRAME_SIZES = [ + '4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36', + '4x4', '8x8', '12x12', +]; + +// Pixels at 300 dpi for preview labels +const FRAME_PX = { + '4x6': [1200, 1800], '5x7': [1500, 2100], + '8x10': [2400, 3000], '11x14': [3300, 4200], + '16x20': [4800, 6000], '18x24': [5400, 7200], + '20x24': [6000, 7200], '24x36': [7200, 10800], + '4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600], +}; + +class Image_frame_fit_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async frame_fit() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + var caps = await getCapabilities(); + var hasRemote = caps.remote && caps.remote.healthy; + + var _this = this; + var W = config.layer.width_original; + var H = config.layer.height_original; + + // Build display labels with pixel sizes + var sizeLabels = FRAME_SIZES.map(s => { + var px = FRAME_PX[s] || [0, 0]; + return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`; + }); + + this.Dialog.show({ + title: 'Fit to Frame', + params: [ + { + title: '', + html: `
    + Current image: ${W}×${H}px
    + Crop = no AI needed. Extend = AI fills the gaps${hasRemote ? '' : ' (no provider configured — extend will use mirror fill)'}. +
    `, + }, + { + name: 'frame', + title: 'Frame size:', + value: sizeLabels[1], // default 5x7 + values: sizeLabels, + type: 'select', + }, + { + name: 'orientation', + title: 'Orientation:', + value: 'auto', + values: ['auto', 'portrait', 'landscape'], + type: 'select', + }, + { + name: 'mode', + title: 'Fit mode:', + value: 'smart', + values: ['smart', 'crop', 'extend'], + type: 'select', + }, + { + name: 'dpi', + title: 'Output DPI:', + value: '300', + values: ['72', '150', '200', '300'], + type: 'select', + }, + { + name: 'prompt', + title: 'Extend prompt (optional):', + value: '', + placeholder: 'e.g. "continue the background naturally" — blank works well', + }, + { + name: 'new_layer', + title: 'Result as new layer (keep original):', + value: true, + }, + ], + on_finish: async function (params) { + var frameKey = params.frame.split('"')[0]; // strip label suffix back to "8x10" + await _this._run(frameKey, params); + }, + }); + } + + async _run(frameKey, params) { + if (this.isProcessing) return; + this.isProcessing = true; + + var mode = params.mode || 'smart'; + showProgress( + mode === 'extend' + ? 'Fitting to frame with AI extension…' + : 'Fitting to frame…', + mode === 'extend' ? 45 : 5 + ); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/frame-fit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + frame: frameKey, + orientation: params.orientation || 'auto', + mode: params.mode || 'smart', + dpi: parseInt(params.dpi) || 300, + prompt: params.prompt || '', + }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Frame fit failed'); + } + var result = await r.json(); + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + var fitW = img.naturalWidth; + var fitH = img.naturalHeight; + + if (params.new_layer) { + var dataURL = img.src; + app.State.do_action( + new app.Actions.Bundle_action('frame_fit_layer', 'Fit to Frame', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: fitW, + HEIGHT: fitH, + }), + new app.Actions.Insert_layer_action({ + name: `${frameKey} fit`, + type: 'image', + data: dataURL, + x: 0, y: 0, + width: fitW, + height: fitH, + width_original: fitW, + height_original: fitH, + }), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('frame_fit', 'Fit to Frame', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: fitW, + HEIGHT: fitH, + }), + new app.Actions.Update_layer_image_action(resultCanvas), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } + + hideProgress(); + alertify.success( + `Done! ${result.output_pixels.width}×${result.output_pixels.height}px` + + ` (${result.frame} ${result.orientation}, ${result.mode_used})` + ); + this.isProcessing = false; + }; + img.onerror = () => { + hideProgress(); + alertify.error('Failed to load result.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + hideProgress(); + alertify.error('Frame fit failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Image_frame_fit_class; diff --git a/paintplus/frontend/src/js/modules/image/histogram.js b/paintplus/frontend/src/js/modules/image/histogram.js new file mode 100644 index 0000000..f9786c5 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/histogram.js @@ -0,0 +1,122 @@ +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; + +class Image_histogram_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + } + + histogram() { + var _this = this; + + var settings = { + title: 'Histogram', + on_change: function (params) { + _this.histogram_onload(params); + }, + params: [ + {name: "channel", title: "Channel:", values: ["Gray", "Red", "Green", "Blue"], }, + {title: 'Histogram:', function: function () { + var html = ''; + return html; + }}, + {title: "Total pixels:", value: ""}, + {title: "Average:", value: ""}, + ], + }; + this.POP.show(settings); + + this.histogram_onload({}); + } + + histogram_onload(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id); + var ctx = canvas.getContext("2d"); + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var imgData = img.data; + + var channel = 0; + if (params.channel == 'Red') + channel = 1; + else if (params.channel == 'Green') + channel = 2; + else if (params.channel == 'Blue') + channel = 3; + + var hist_data = [[], [], [], []]; //grey, red, green, blue + var total = imgData.length / 4; + var sum = 0; + var grey; + + for (var i = 0; i < imgData.length; i += 4) { + //collect grey + grey = Math.round((imgData[i] + imgData[i + 1] + imgData[i + 2]) / 3); + sum = sum + imgData[i] + imgData[i + 1] + imgData[i + 2]; + if (hist_data[0][grey] == undefined) + hist_data[0][grey] = 1; + else + hist_data[0][grey]++; + + //collect colors + for (var c = 0; c < 3; c++) { + if (c + 1 != channel) + continue; + if (hist_data[c + 1][imgData[i + c]] == undefined) + hist_data[c + 1][imgData[i + c]] = 1; + else + hist_data[c + 1][imgData[i + c]]++; + } + } + + var c = document.getElementById("c_h").getContext("2d"); + c.rect(0, 0, 256, 100); + c.fillStyle = "#ffffff"; + c.fill(); + var opacity = 1; + + //draw histogram + for (var h in hist_data) { + for (var i = 0; i <= 255; i++) { + if (h != channel) + continue; + if (hist_data[h][i] == 0) + continue; + c.beginPath(); + + if (h == 0) + c.strokeStyle = "rgba(64, 64, 64, " + opacity * 2 + ")"; + else if (h == 1) + c.strokeStyle = "rgba(255, 0, 0, " + opacity + ")"; + else if (h == 2) + c.strokeStyle = "rgba(0, 255, 0, " + opacity + ")"; + else if (h == 3) + c.strokeStyle = "rgba(0, 0, 255, " + opacity + ")"; + + c.lineWidth = 1; + c.moveTo(i + 0.5, 100 + 0.5); + c.lineTo(i + 0.5, 100 - Math.round(hist_data[h][i] * 255 * 100 / total / 6) + 0.5); + c.stroke(); + } + } + + document.getElementById("pop_data_totalpixel").innerHTML = this.Helper.number_format(total, 0); + var average; + if (total > 0) + average = Math.round(sum * 10 / total / 3) / 10; + else + average = '-'; + document.getElementById("pop_data_average").innerHTML = average; + + canvas.width = 1; + canvas.height = 1; + } + +} + +export default Image_histogram_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/information.js b/paintplus/frontend/src/js/modules/image/information.js new file mode 100644 index 0000000..da7a307 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/information.js @@ -0,0 +1,144 @@ +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Tools_settings_class from './../tools/settings.js'; + +var instance = null; + +class Image_information_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.Tools_settings = new Tools_settings_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.key.toLowerCase(); + if (this.Helper.is_input(event.target)) + return; + + if (code == "i") { + this.information(); + event.preventDefault(); + } + }, false); + } + + information() { + var _this = this; + var pixels = config.WIDTH * config.HEIGHT; + pixels = this.Helper.number_format(pixels, 0); + + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var width = this.Helper.get_user_unit(config.WIDTH, units, resolution); + var height = this.Helper.get_user_unit(config.HEIGHT, units, resolution); + + var settings = { + title: 'Information', + params: [ + {title: "Width:", value: width + ' ' + units}, + {title: "Height:", value: height + ' ' + units}, + {title: "Pixels:", value: pixels}, + {title: "Layers:", value: config.layers.length}, + {title: "Unique colors:", value: '...'}, + ], + }; + if(units != 'pixels'){ + settings.params[0].value += " (" + config.WIDTH + " pixels)"; + settings.params[1].value += " (" + config.HEIGHT + " pixels)"; + } + + //exif data + if (config.layer._exif != undefined) { + //show exif and general data + var exif_data = config.layer._exif; + + //show general data + for (var i in exif_data.general) { + settings.params.push({title: i + ":", value: exif_data.general[i]}); + } + + //show exif data + var n = 0; + for (var i in exif_data.exif) { + if (i == 'undefined') + continue; + if (n == 0) + settings.params.push({title: "==== EXIF ====", value: ''}); + settings.params.push({title: i + ":", value: exif_data.exif[i]}); + n++; + } + } + + this.POP.show(settings); + + //calc colors + setTimeout(function () { + var colors = _this.unique_colors_count(); + colors = _this.Helper.number_format(colors, 0); + document.getElementById('pop_data_uniquecolo').innerHTML = colors; + }, 50); + } + + unique_colors_count() { + var method = 'v2'; //v1 or v2 + + if (config.WIDTH * config.HEIGHT > 20 * 1000 * 1000) { + return '-'; + } + + var canvas = this.Base_layers.convert_layer_to_canvas(); + var ctx = canvas.getContext("2d"); + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var imgData = img.data; + + //v1 - simple, slow + if (method == 'v1') { + var colors = []; + var n = 0; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + var key = imgData[i] + "." + imgData[i + 1] + "." + imgData[i + 2]; + if (colors[key] == undefined) { + colors[key] = 1; + n++; + } + } + } + + //v2 - 30% faster + else if (method == 'v2') { + var buffer32 = new Uint32Array(imgData.buffer); + var len = buffer32.length; + var stats = {}; + var n = 0; + + for (var i = 0; i < len; i++) { + var key = "" + (buffer32[i] & 0xffffff); + if (stats[key] == undefined) { + stats[key] = 0; + n++; + } + } + } + + return n; + } +} + +export default Image_information_class; diff --git a/paintplus/frontend/src/js/modules/image/opacity.js b/paintplus/frontend/src/js/modules/image/opacity.js new file mode 100644 index 0000000..0a73aa4 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/opacity.js @@ -0,0 +1,56 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; + +class Image_opacity_class { + + constructor() { + this.POP = new Dialog_class(); + } + + opacity() { + var _this = this; + var initial_opacity = config.layer.opacity; + + var settings = { + title: 'Opacity', + params: [ + {name: "opacity", title: "Alpha:", value: config.layer.opacity, range: [0, 100]}, + ], + on_change: function (params, canvas_preview, w, h) { + _this.opacity_handler(params, false); + }, + on_finish: function (params) { + config.layer.opacity = initial_opacity; + _this.opacity_handler(params); + }, + on_cancel: function (params) { + config.layer.opacity = initial_opacity; + config.need_render = true; + }, + }; + this.POP.show(settings); + } + + opacity_handler(data, is_final = true) { + var value = parseInt(data.opacity); + if (value < 0) + value = 0; + if (value > 100) + value = 100; + if (is_final) { + app.State.do_action( + new app.Actions.Bundle_action('change_opacity', 'Change Opacity', [ + new app.Actions.Update_layer_action(config.layer.id, { + opacity: value + }) + ]) + ); + } else { + config.layer.opacity = value; + config.need_render = true; + } + } +} + +export default Image_opacity_class; diff --git a/paintplus/frontend/src/js/modules/image/palette.js b/paintplus/frontend/src/js/modules/image/palette.js new file mode 100644 index 0000000..0d7ca80 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/palette.js @@ -0,0 +1,53 @@ +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import colorThief_class from './../../libs/color-thief.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; + +class Image_color_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + this.alertify = new colorThief_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + } + + palette() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + var palette = this.alertify.getPalette(config.layer.link); + var dominant = this.alertify.getColor(config.layer.link); + dominant = this.Helper.rgbToHex(dominant[0], dominant[1], dominant[2]); + + var settings = { + title: 'Palette', + params: [ + {title: "Dominant color:", html: this.generate_color_box(dominant, 200)}, + ], + }; + for (var i in palette) { + var rgb = this.Helper.rgbToHex(palette[i][0], palette[i][1], palette[i][2]); + i = parseInt(i); + settings.params.push( + {title: "Color #" + (i + 1) + ":", html: this.generate_color_box(rgb, 100)} + ); + } + this.POP.show(settings); + } + + generate_color_box(color, width) { + var html = ''; + + html += ''; + html += ''; + + return html; + } + +} + +export default Image_color_class; diff --git a/paintplus/frontend/src/js/modules/image/print_prepare.js b/paintplus/frontend/src/js/modules/image/print_prepare.js new file mode 100644 index 0000000..cbe84b2 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/print_prepare.js @@ -0,0 +1,287 @@ +/** + * Prepare for Print — one-click AI upscale + frame fit. + * + * Shows a quality assessment (current effective DPI, needed upscale factor, + * AI vs Lanczos note) then chains AI upscale → frame-fit in a single backend call. + * + * Menu target: image/print_prepare.print_prepare + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { getCapabilities } from './../../api/capabilities.js'; +import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; + +const FRAME_SIZES = [ + '5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36', +]; + +// Portrait pixels at 300 DPI (label use only) +const FRAME_PX = { + '5x7': [1500, 2100], '8x10': [2400, 3000], + '11x14': [3300, 4200], '18x24': [5400, 7200], + '16x20': [4800, 6000], '20x24': [6000, 7200], + '24x36': [7200, 10800], +}; + +// Actual frame inches (portrait w, h) +const FRAME_IN = { + '5x7': [5, 7], '8x10': [8, 10], '11x14': [11, 14], + '18x24': [18, 24], '16x20': [16, 20], '20x24': [20, 24], + '24x36': [24, 36], +}; + +var instance = null; + +class Image_print_prepare_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async print_prepare() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + var caps = await getCapabilities(); + var hasAI = (caps.remote && caps.remote.healthy) || (caps.local && caps.local.local_gpu_available); + + var W = config.layer.width_original; + var H = config.layer.height_original; + + var qualityHtml = _buildQualityHtml(W, H, hasAI); + + var frameLabels = FRAME_SIZES.map(s => { + var px = FRAME_PX[s] || [0, 0]; + return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`; + }); + + var _this = this; + this.Dialog.show({ + title: 'Prepare for Print', + params: [ + { + title: '', + html: qualityHtml, + }, + { + name: 'frame', + title: 'Target frame size:', + value: frameLabels[0], + values: frameLabels, + type: 'select', + }, + { + name: 'orientation', + title: 'Orientation:', + value: 'auto', + values: ['auto', 'portrait', 'landscape'], + type: 'select', + }, + { + name: 'target_dpi', + title: 'Target DPI:', + value: '300', + values: ['200', '300'], + type: 'select', + comment: '200 dpi is fine for 18×24" and larger (viewed from a distance)', + }, + { + name: 'mode', + title: 'Fit mode:', + value: 'smart', + values: ['smart', 'crop', 'extend'], + type: 'select', + comment: 'smart = extend if gap <15%, else crop', + }, + { + name: 'upscale_method', + title: 'Upscale engine:', + value: 'auto', + values: ['auto', 'realesrgan_pytorch', 'realesrgan_ncnn', 'lanczos'], + type: 'select', + comment: hasAI ? 'auto picks Real-ESRGAN — genuinely adds detail' : 'auto picks Real-ESRGAN if available, else Lanczos', + }, + { + name: 'prompt', + title: 'Extend prompt (optional):', + value: '', + placeholder: 'e.g. "natural background continuation" — blank works well', + }, + { + name: 'new_layer', + title: 'Result as new layer (keep original):', + value: true, + }, + ], + on_finish: async function (params) { + var frameKey = params.frame.split('"')[0]; + await _this._run(frameKey, params, W, H); + }, + }); + } + + async _run(frameKey, params, origW, origH) { + if (this.isProcessing) return; + this.isProcessing = true; + + var dpi = parseInt(params.target_dpi) || 300; + var inches = FRAME_IN[frameKey] || [8, 10]; + var targetW = inches[0] * dpi; + var targetH = inches[1] * dpi; + + // Orientation swap for display + var orient = params.orientation || 'auto'; + var imgLandscape = origW >= origH; + var frameLandscape = inches[0] >= inches[1]; + if (orient === 'landscape' || (orient === 'auto' && imgLandscape && !frameLandscape)) { + targetW = Math.max(inches[0], inches[1]) * dpi; + targetH = Math.min(inches[0], inches[1]) * dpi; + } else if (orient === 'portrait' || (orient === 'auto' && !imgLandscape && frameLandscape)) { + targetW = Math.min(inches[0], inches[1]) * dpi; + targetH = Math.max(inches[0], inches[1]) * dpi; + } + + var neededScale = Math.max(targetW / origW, targetH / origH); + var willUpscale = neededScale > 1.05; + + showProgress( + willUpscale + ? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame…\nAI is reconstructing detail — this may take 1–3 minutes.` + : 'Fitting to frame…', + willUpscale ? 120 : 8 + ); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = origW; + layerCanvas.height = origH; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/prepare`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + frame: frameKey, + orientation: orient, + target_dpi: dpi, + upscale_method: params.upscale_method || 'auto', + mode: params.mode || 'smart', + prompt: params.prompt || '', + }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Prepare failed'); + } + var result = await r.json(); + + updateProgress(90, 'Placing result…'); + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + var fitW = img.naturalWidth; + var fitH = img.naturalHeight; + + if (params.new_layer) { + app.State.do_action( + new app.Actions.Bundle_action('print_prepare_layer', 'Prepare for Print', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }), + new app.Actions.Insert_layer_action({ + name: `${frameKey} ${dpi}dpi`, + type: 'image', + data: img.src, + x: 0, y: 0, + width: fitW, height: fitH, + width_original: fitW, height_original: fitH, + }), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('print_prepare', 'Prepare for Print', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }), + new app.Actions.Update_layer_image_action(resultCanvas), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } + + hideProgress(); + var upscaleNote = result.upscale_applied + ? ` · ${result.upscale_factor}× ${result.upscale_method}` + : ' · no upscale needed'; + alertify.success( + `Print-ready! ${fitW}×${fitH}px @ ${dpi} DPI (${frameKey}")${upscaleNote}` + ); + this.isProcessing = false; + }; + img.onerror = () => { + hideProgress(); + alertify.error('Failed to load result.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + hideProgress(); + alertify.error('Prepare for Print failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +function _buildQualityHtml(W, H, hasAI) { + var rows = FRAME_SIZES.map(key => { + var inches = FRAME_IN[key]; + // Effective DPI: smaller of the two dimensions (limiting factor) + var effDpi = Math.round(Math.min(W / inches[0], H / inches[1])); + var quality = effDpi >= 300 ? '✓ excellent' + : effDpi >= 200 ? '✓ good for large format' + : effDpi >= 150 ? '~ acceptable' + : '✗ needs upscaling'; + var color = effDpi >= 300 ? '#44cc44' + : effDpi >= 200 ? '#88cc44' + : effDpi >= 150 ? '#ffaa44' + : '#ff6644'; + var neededScale = Math.max(1, Math.ceil((300 / effDpi) * 10) / 10); + var scaleNote = effDpi >= 300 ? '' : ` → need ~${neededScale.toFixed(1)}× upscale`; + return ` + ${key}" + ${effDpi} DPI + ${quality}${scaleNote} + `; + }).join(''); + + var aiNote = hasAI + ? 'Real-ESRGAN available — will add genuine sharpness (AI reconstructs detail)' + : 'No AI provider — will use Lanczos (resizes but doesn\'t add detail)'; + + return `
    +
    Current image: ${W}×${H}px · ${aiNote}
    + ${rows}
    +
    200 DPI is fine for 18×24" and larger prints viewed from 2+ feet.
    +
    `; +} + +export default Image_print_prepare_class; diff --git a/paintplus/frontend/src/js/modules/image/remove_background.js b/paintplus/frontend/src/js/modules/image/remove_background.js new file mode 100644 index 0000000..923479a --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/remove_background.js @@ -0,0 +1,150 @@ +/** + * Remove Background Module - Uses AI to remove background and create transparent layer + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../../services/api.js'; + +var instance = null; + +class Image_remove_background_class { + + constructor() { + // Singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + /** + * Remove background from current layer + * Creates a new layer with transparent background + */ + async remove_background() { + var _this = this; + + if (this.isProcessing) { + alertify.warning('Already processing... please wait'); + return; + } + + // Check if current layer is an image + if (config.layer.type != 'image') { + alertify.error('Current layer must be an image'); + return; + } + + var settings = { + title: 'Remove Background', + params: [ + { name: "info", title: "AI will detect the main subject and remove the background.", type: "label" }, + { + name: "model", title: "Model:", value: "auto", type: "select", + values: ["auto", "ben2", "birefnet-hr", "u2net"], + comment: "auto = best available (BEN2 by default). BiRefNet-HR is slower but sharper on high-res/print work.", + }, + { name: "new_layer", title: "Create as new layer:", value: true }, + { name: "trim_result", title: "Trim transparent edges:", value: false }, + ], + on_finish: async function (params) { + await _this.do_remove_background(params); + }, + }; + this.Dialog.show(settings); + } + + async do_remove_background(params) { + this.isProcessing = true; + alertify.message('AI is removing background... this may take a moment'); + + try { + // Get current layer image as base64 + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + ctx.drawImage(config.layer.link, 0, 0); + + var imageData = canvas.toDataURL('image/png').split(',')[1]; + + // Call backend API + var result = await apiService.removeBackground(imageData, params.model); + + // Create image from result + var resultImage = new Image(); + resultImage.onload = () => { + if (params.new_layer) { + // Create as new layer + var layerParams = { + x: config.layer.x, + y: config.layer.y, + width: resultImage.width, + height: resultImage.height, + width_original: resultImage.width, + height_original: resultImage.height, + type: 'image', + name: config.layer.name + ' (No BG)', + data: 'data:image/png;base64,' + result.result + }; + + app.State.do_action( + new app.Actions.Bundle_action('remove_background', 'Remove Background', [ + new app.Actions.Insert_layer_action(layerParams) + ]) + ); + + alertify.success('Background removed! New layer created.'); + } else { + // Replace current layer + var newCanvas = document.createElement('canvas'); + newCanvas.width = resultImage.width; + newCanvas.height = resultImage.height; + var newCtx = newCanvas.getContext('2d'); + newCtx.drawImage(resultImage, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('remove_background', 'Remove Background', [ + new app.Actions.Update_layer_image_action(newCanvas, config.layer.id) + ]) + ); + + alertify.success('Background removed!'); + } + + // Enable transparency if not already + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + this.Base_layers.render(); + alertify.message('Transparency enabled to show removed background'); + } + + this.isProcessing = false; + }; + + resultImage.onerror = () => { + alertify.error('Failed to load result image'); + this.isProcessing = false; + }; + + resultImage.src = 'data:image/png;base64,' + result.result; + + } catch (error) { + console.error('Remove background error:', error); + alertify.error('Failed to remove background: ' + error.message); + this.isProcessing = false; + } + } +} + +export default Image_remove_background_class; diff --git a/paintplus/frontend/src/js/modules/image/replace_subject.js b/paintplus/frontend/src/js/modules/image/replace_subject.js new file mode 100644 index 0000000..796aadd --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/replace_subject.js @@ -0,0 +1,252 @@ +/** + * Replace Subject — extract the primary subject from a source photo and + * composite it onto the current layer's background. + * + * Workflow: + * 1. User selects the subject area via Smart Select (optional but recommended). + * 2. Opens this module → picks a source photo. + * 3. Backend removes the background from the source photo (rembg / AI), + * scales the extracted subject to fit the selection (or the canvas center), + * applies LAB color transfer so the lighting matches the background, and + * returns the composited image. + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { showProgress, hideProgress } from './../../libs/progress_overlay.js'; + +const BASE = window.API_BASE_URL || ''; + +var instance = null; + +class Image_replace_subject_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.isProcessing = false; + } + + replace_subject() { + if (this.isProcessing) { + alertify.warning('Already processing… please wait'); + return; + } + if (config.layer.type !== 'image') { + alertify.error('Current layer must be an image.'); + return; + } + this._showDialog(); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + _showDialog() { + var hasSel = !!(window.smartSelectMask && window.smartSelectMask.canvas); + + // Build a dialog manually so we can embed a file input + var overlay = document.createElement('div'); + overlay.style.cssText = [ + 'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.6)', + 'z-index:20000', 'display:flex', 'align-items:center', 'justify-content:center', + ].join(';'); + + var box = document.createElement('div'); + box.style.cssText = [ + 'background:#1a1a2e', 'border:1px solid #3a3a6a', 'border-radius:12px', + 'padding:24px', 'min-width:380px', 'max-width:460px', + 'font-family:sans-serif', 'color:#d0d0e0', 'font-size:13px', + ].join(';'); + + // Title + var title = document.createElement('div'); + title.textContent = 'Replace Subject'; + title.style.cssText = 'font-size:16px;font-weight:bold;color:#aaaaff;margin-bottom:6px'; + box.appendChild(title); + + var sub = document.createElement('div'); + sub.textContent = hasSel + ? 'Subject will be placed inside your current selection.' + : 'No selection active — subject will be centred on the canvas. Use Smart Select first for precise placement.'; + sub.style.cssText = 'font-size:11px;color:#7777aa;margin-bottom:16px;line-height:1.4'; + box.appendChild(sub); + + // File picker label + preview row + var fileRow = document.createElement('div'); + fileRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:12px'; + var fileLabel = document.createElement('label'); + fileLabel.textContent = 'Source photo:'; + fileLabel.style.cssText = 'color:#aaa;width:100px;flex-shrink:0'; + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + fileInput.style.cssText = 'flex:1;background:#0f0f1a;color:#ccc;border:1px solid #4a4a8a;border-radius:5px;padding:4px 8px;font-size:12px;cursor:pointer'; + fileRow.appendChild(fileLabel); + fileRow.appendChild(fileInput); + box.appendChild(fileRow); + + // Thumbnail preview + var preview = document.createElement('img'); + preview.style.cssText = 'display:none;max-width:100%;max-height:160px;border-radius:6px;margin-bottom:12px;border:1px solid #3a3a6a'; + box.appendChild(preview); + fileInput.addEventListener('change', () => { + var f = fileInput.files[0]; + if (!f) return; + var url = URL.createObjectURL(f); + preview.src = url; + preview.style.display = 'block'; + preview.onload = () => URL.revokeObjectURL(url); + }); + + // Match colors checkbox + var colorRow = document.createElement('div'); + colorRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:16px'; + var colorCheck = document.createElement('input'); + colorCheck.type = 'checkbox'; + colorCheck.checked = true; + colorCheck.id = 'rs-match-colors'; + var colorLabel = document.createElement('label'); + colorLabel.htmlFor = 'rs-match-colors'; + colorLabel.textContent = 'Match background lighting & color tone'; + colorLabel.style.cssText = 'color:#bbb;cursor:pointer'; + colorRow.appendChild(colorCheck); + colorRow.appendChild(colorLabel); + box.appendChild(colorRow); + + // Buttons + var btnRow = document.createElement('div'); + btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end'; + + var cancelBtn = _btn('Cancel', '#2a2a4a', '#8888aa'); + cancelBtn.onclick = () => { document.body.removeChild(overlay); }; + + var goBtn = _btn('Replace Subject', '#1a3a5a', '#88ccff'); + goBtn.style.fontWeight = 'bold'; + goBtn.onclick = async () => { + var file = fileInput.files[0]; + if (!file) { + alertify.warning('Please pick a source photo first.'); + return; + } + document.body.removeChild(overlay); + await this._run(file, colorCheck.checked); + }; + + btnRow.appendChild(cancelBtn); + btnRow.appendChild(goBtn); + box.appendChild(btnRow); + + overlay.appendChild(box); + overlay.addEventListener('click', (e) => { if (e.target === overlay) document.body.removeChild(overlay); }); + document.body.appendChild(overlay); + } + + async _run(file, matchColors) { + this.isProcessing = true; + showProgress('Extracting subject and compositing…', 20); + + try { + var subjectBase64 = await _fileToBase64(file); + var bgBase64 = _getLayerBase64(); + var maskBase64 = _getMaskBase64(); + + var res = await _post('/api/image/replace-subject', { + background_image: bgBase64, + subject_image: subjectBase64, + mask: maskBase64 || undefined, + match_colors: matchColors, + }); + + var img = new Image(); + img.onload = () => { + var canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext('2d').drawImage(img, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('replace_subject', 'Replace Subject', [ + new app.Actions.Update_layer_image_action(canvas, config.layer.id) + ]) + ); + + // Clear selection if one was used + if (window.smartSelectMask) { + window.smartSelectMask = null; + config.need_render = true; + } + + hideProgress(); + alertify.success('Subject replaced!'); + this.isProcessing = false; + }; + img.onerror = () => { + hideProgress(); + alertify.error('Failed to load result image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + res.result; + + } catch (e) { + hideProgress(); + alertify.error('Replace subject failed: ' + (e.message || e)); + this.isProcessing = false; + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _getLayerBase64() { + var canvas = document.createElement('canvas'); + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + canvas.getContext('2d').drawImage(config.layer.link, 0, 0); + return canvas.toDataURL('image/png').split(',')[1]; +} + +function _getMaskBase64() { + var m = window.smartSelectMask; + if (!m || !m.canvas) return null; + var w = config.layer.width_original; + var h = config.layer.height_original; + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + canvas.getContext('2d').drawImage(m.canvas, 0, 0, w, h); + return canvas.toDataURL('image/png').split(',')[1]; +} + +function _fileToBase64(file) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +function _btn(text, bg, color) { + var b = document.createElement('button'); + b.textContent = text; + b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px'; + return b; +} + +async function _post(path, body) { + var r = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(err.detail || 'Request failed'); + } + return r.json(); +} + +export default Image_replace_subject_class; diff --git a/paintplus/frontend/src/js/modules/image/resize.js b/paintplus/frontend/src/js/modules/image/resize.js new file mode 100644 index 0000000..4385bb7 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/resize.js @@ -0,0 +1,477 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Dialog_class from './../../libs/popup.js'; +import ImageFilters_class from './../../libs/imagefilters.js'; +import Hermite_class from 'hermite-resize'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Pica from './../../../../node_modules/pica/dist/pica.js'; +import Helper_class from './../../libs/helpers.js'; +import Tools_settings_class from './../tools/settings.js'; +import { metaDefaults as textMetaDefaults } from '../../tools/text.js'; + +var instance = null; + +class Image_resize_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.POP = new Dialog_class(); + this.ImageFilters = ImageFilters_class; + this.Hermite = new Hermite_class(); + this.Tools_settings = new Tools_settings_class(); + this.pica = Pica(); + this.Helper = new Helper_class(); + this._lastUnits = 'pixels'; + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 82 && event.ctrlKey != true && event.metaKey != true) { + //R - resize + this.resize(); + event.preventDefault(); + } + }, false); + } + + resize() { + var _this = this; + var savedUnits = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var displayUnits = (savedUnits === 'inches') ? 'inches' : 'pixels'; + this._lastUnits = displayUnits; + + var width = this.Helper.get_user_unit(config.WIDTH, displayUnits, resolution); + var height = this.Helper.get_user_unit(config.HEIGHT, displayUnits, resolution); + + var settings = { + title: 'Resize', + params: [ + {name: "units", title: "Units:", value: displayUnits, values: ["pixels", "inches"]}, + {name: "width", title: "Width:", value: '', placeholder: width, comment: displayUnits}, + {name: "height", title: "Height:", value: '', placeholder: height, comment: displayUnits}, + {name: "width_percent", title: "Width (%):", value: '', placeholder: 100, comment: "%"}, + {name: "height_percent", title: "Height (%):", value: '', placeholder: 100, comment: "%"}, + {name: "mode", title: "Mode:", values: ["Lanczos", "Hermite", "Basic"]}, + {name: "crop_to_fill", title: "Crop to fill:", value: false}, + {name: "sharpen", title: "Sharpen:", value: false}, + {name: "layers", title: "Layers:", values: ["All", "Active"], value: "All"}, + ], + on_change: function(params) { + _this.units_change_handler(params); + }, + on_finish: function (params) { + _this.do_resize(params); + }, + }; + this.POP.show(settings); + + document.getElementById("pop_data_width").select(); + } + + /** + * Called on any dialog field change; reacts only when the units radio switches. + * Updates width/height placeholders and labels, and persists the choice globally. + */ + units_change_handler(params) { + var units = params.units; + if (units === this._lastUnits) return; + + this._lastUnits = units; + var resolution = this.Tools_settings.get_setting('resolution'); + + // Persist so Canvas Size and other dialogs open with the same units + const unitShort = {pixels: 'px', inches: '"', centimeters: 'cm', millimetres: 'mm'}; + this.Tools_settings.save_setting('default_units', units); + this.Tools_settings.save_setting('default_units_short', unitShort[units] || units); + + var newWidth = this.Helper.get_user_unit(config.WIDTH, units, resolution); + var newHeight = this.Helper.get_user_unit(config.HEIGHT, units, resolution); + + var widthInput = document.getElementById('pop_data_width'); + var heightInput = document.getElementById('pop_data_height'); + if (widthInput) { + widthInput.placeholder = newWidth; + widthInput.value = ''; + } + if (heightInput) { + heightInput.placeholder = newHeight; + heightInput.value = ''; + } + + var wComment = widthInput ? widthInput.nextElementSibling : null; + var hComment = heightInput ? heightInput.nextElementSibling : null; + if (wComment && wComment.classList.contains('field_comment')) wComment.textContent = units; + if (hComment && hComment.classList.contains('field_comment')) hComment.textContent = units; + } + + async do_resize(params) { + //validate + if (isNaN(params.width) && isNaN(params.height) && isNaN(params.width_percent) && isNaN(params.height_percent)) { + alertify.error('Missing at least 1 size parameter.'); + return false; + } + + // Crop-to-fill: scale to cover then center-crop; requires both dimensions + if (params.crop_to_fill == true) { + if (isNaN(params.width) || isNaN(params.height)) { + alertify.error('Crop to fill requires both Width and Height.'); + return false; + } + if (params.layers == 'All') { + return this.do_resize_crop_fill(params); + } + } + + // Build a list of actions to execute for resize + let actions = []; + + if (params.layers == 'All') { + //resize all layers + var skips = 0; + for (var i in config.layers) { + try { + actions = actions.concat(await this.resize_layer(config.layers[i], params)); + } catch (error) { + skips++; + } + } + if (skips > 0) { + alertify.error(skips + ' layer(s) were skipped.'); + } + actions = actions.concat(this.resize_gui(params)); + } + else { + //only active + actions = actions.concat(await this.resize_layer(config.layer, params)); + } + return app.State.do_action( + new app.Actions.Bundle_action('resize_layers', 'Resize Layers', actions) + ); + } + + /** + * Resize all image layers using cover-scale then center-crop so the subject + * looks the same regardless of target aspect ratio (no stretching). + */ + async do_resize_crop_fill(params) { + var units = params.units || this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var targetWidth = this.Helper.get_internal_unit(parseFloat(params.width), units, resolution); + var targetHeight = this.Helper.get_internal_unit(parseFloat(params.height), units, resolution); + targetWidth = parseInt(targetWidth); + targetHeight = parseInt(targetHeight); + + if (!targetWidth || !targetHeight || targetWidth < 1 || targetHeight < 1) { + alertify.error('Invalid dimensions for crop to fill.'); + return; + } + + var srcWidth = config.WIDTH; + var srcHeight = config.HEIGHT; + + // Cover scale: image fills target, excess is cropped from center + var scale = Math.max(targetWidth / srcWidth, targetHeight / srcHeight); + var scaledW = Math.round(srcWidth * scale); + var scaledH = Math.round(srcHeight * scale); + var cropX = Math.round((scaledW - targetWidth) / 2); + var cropY = Math.round((scaledH - targetHeight) / 2); + + var mode = params.mode; + var sharpen = params.sharpen; + let actions = []; + + for (var i in config.layers) { + var layer = config.layers[i]; + if (layer.type !== 'image') continue; + if (layer.width == null || layer.height == null) continue; + + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var newLayerW = Math.round(layer.width * scale); + var newLayerH = Math.round(layer.height * scale); + + var useMode = mode; + if (useMode == "Hermite" && (newLayerW > canvas.width || newLayerH > canvas.height)) { + useMode = "Lanczos"; + } + + var tmp = document.createElement('canvas'); + tmp.width = newLayerW; + tmp.height = newLayerH; + + if (useMode == "Lanczos") { + await this.pica.resize(canvas, tmp, {alpha: true}); + } else if (useMode == "Hermite") { + tmp.getContext('2d').drawImage(canvas, 0, 0); + this.Hermite.resample_single(tmp, newLayerW, newLayerH, true); + } else { + tmp.getContext('2d').drawImage(canvas, 0, 0, newLayerW, newLayerH); + } + + if (sharpen == true) { + var ctx = tmp.getContext('2d'); + var imageData = ctx.getImageData(0, 0, tmp.width, tmp.height); + ctx.putImageData(this.ImageFilters.Sharpen(imageData, 1), 0, 0); + } + + var newX = Math.round(layer.x * scale) - cropX; + var newY = Math.round(layer.y * scale) - cropY; + + actions.push(new app.Actions.Update_layer_image_action(tmp, layer.id)); + actions.push(new app.Actions.Update_layer_action(layer.id, { + x: newX, + y: newY, + width: newLayerW, + height: newLayerH, + width_original: newLayerW, + height_original: newLayerH, + })); + } + + // Update canvas dimensions to exact target + actions = actions.concat([ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: targetWidth, + HEIGHT: targetHeight, + }), + new app.Actions.Prepare_canvas_action('do'), + ]); + + return app.State.do_action( + new app.Actions.Bundle_action('resize_layers', 'Resize Layers', actions) + ); + } + + /** + * Generates actions that will resize layer (image, text, vector), returns a promise that rejects on failure. + * + * @param {object} layer + * @param {object} params + * @returns {Promise} Returns array of actions to perform + */ + async resize_layer(layer, params) { + var units = params.units || this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + var mode = params.mode; + var width = parseFloat(params.width); + var height = parseFloat(params.height); + var width_100 = parseInt(params.width_percent); + var height_100 = parseInt(params.height_percent); + var canvas_width = layer.width; + var canvas_height = layer.height; + var sharpen = params.sharpen; + var _this = this; + + //convert units + if (isNaN(width) == false){ + width = this.Helper.get_internal_unit(width, units, resolution); + } + if (isNaN(height) == false){ + height = this.Helper.get_internal_unit(height, units, resolution); + } + + //if dimension with percent provided + if (isNaN(width) && isNaN(height)) { + if (isNaN(width_100) == false) { + width = Math.round(config.WIDTH * width_100 / 100); + canvas_width = Math.round(config.WIDTH * width_100 / 100); + } + if (isNaN(height_100) == false) { + height = Math.round(config.HEIGHT * height_100 / 100); + canvas_height = Math.round(config.HEIGHT * height_100 / 100); + } + } + + //if only 1 dimension was provided + if (isNaN(width) || isNaN(height)) { + var ratio = layer.width / layer.height; + var canvas_ratio = config.WIDTH / config.HEIGHT; + if (isNaN(width)) + width = Math.round(height * ratio); + canvas_width = Math.round(canvas_height * canvas_ratio); + if (isNaN(height)) + height = Math.round(width / ratio); + canvas_height = Math.round(canvas_width / canvas_ratio); + } + + let new_x = params.layers == 'All' ? Math.round(layer.x * width / config.WIDTH) : layer.x; + let new_y = params.layers == 'All' ? Math.round(layer.y * height / config.HEIGHT) : layer.y; + let xratio = width / config.WIDTH; + let yratio = height / config.HEIGHT; + + //is text + if (layer.type == 'text') { + let data = JSON.parse(JSON.stringify(layer.data)); + for (let line of data) { + for (let span of line) { + span.meta.size = Math.ceil((span.meta.size || textMetaDefaults.size) * xratio); + span.meta.stroke_size = parseFloat((0.1 * Math.round((span.meta.stroke_size != null ? span.meta.stroke_size : textMetaDefaults.stroke_size) * xratio / 0.1)).toFixed(1)); + span.meta.kerning = Math.ceil((span.meta.kerning || textMetaDefaults.kerning) * xratio); + } + } + + // Return actions + return [ + new app.Actions.Update_layer_action(layer.id, { + x: new_x, + y: new_y, + data, + width: layer.width * xratio, + height: layer.height * yratio + }) + ]; + } + + //is vector + else if (layer.is_vector == true && layer.width != null && layer.height != null) { + // Return actions + return [ + new app.Actions.Update_layer_action(layer.id, { + x: new_x, + y: new_y, + width: layer.width * xratio, + height: layer.height * yratio + }) + ]; + } + + //only images supported at this point + else if (layer.type != 'image') { + //error - no support + alertify.error('Layer must be vector or image (convert it to raster).'); + throw new Error('Layer is not compatible with resize'); + } + + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var ctx = canvas.getContext("2d"); + + //validate + if (mode == "Hermite" && (width > canvas.width || height > canvas.height)) { + alertify.warning('Scaling up is not supported in Hermite, using Lanczos.'); + mode = "Lanczos"; + } + + //resize + if (mode == "Lanczos") { + //Pica resize with max quality + + var tmp_data = document.createElement("canvas"); + tmp_data.width = width; + tmp_data.height = height; + + await this.pica.resize(canvas, tmp_data, { + alpha: true, + }) + .then((result) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + canvas.width = width; + canvas.height = height; + ctx.drawImage(tmp_data, 0, 0, width, height); + }); + } + else if (mode == "Hermite") { + //Hermite resample + this.Hermite.resample_single(canvas, width, height, true); + } + else { + //simple resize + var tmp_data = document.createElement("canvas"); + tmp_data.width = canvas.width; + tmp_data.height = canvas.height; + tmp_data.getContext("2d").drawImage(canvas, 0, 0); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + canvas.width = width; + canvas.height = height; + + ctx.drawImage(tmp_data, 0, 0, width, height); + } + + if (sharpen == true) { + var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + var filtered = _this.ImageFilters.Sharpen(imageData, 1); //add effect + ctx.putImageData(filtered, 0, 0); + } + + // Return actions + return [ + new app.Actions.Update_layer_image_action(canvas, layer.id), + new app.Actions.Update_layer_action(layer.id, { + x: new_x, + y: new_y, + width: canvas.width, + height: canvas.height, + width_original: canvas.width, + height_original: canvas.height + }) + ]; + } + + resize_gui(params) { + var units = params.units || this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var width = parseFloat(params.width); + var height = parseFloat(params.height); + var width_100 = parseInt(params.width_percent); + var height_100 = parseInt(params.height_percent); + + //convert units + if (isNaN(width) == false){ + width = this.Helper.get_internal_unit(width, units, resolution); + } + if (isNaN(height) == false){ + height = this.Helper.get_internal_unit(height, units, resolution); + } + + //if dimension with percent provided + if (isNaN(width) && isNaN(height)) { + if (isNaN(width_100) == false) { + width = Math.round(config.WIDTH * width_100 / 100); + } + if (isNaN(height_100) == false) { + height = Math.round(config.HEIGHT * height_100 / 100); + } + } + + //if only 1 dimension was provided + if (isNaN(width) || isNaN(height)) { + var ratio = config.WIDTH / config.HEIGHT; + if (isNaN(width)) + width = Math.round(height * ratio); + if (isNaN(height)) + height = Math.round(width / ratio); + } + + return [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: parseInt(width), + HEIGHT: parseInt(height) + }), + new app.Actions.Prepare_canvas_action('do') + ]; + } + +} + +export default Image_resize_class; diff --git a/paintplus/frontend/src/js/modules/image/rotate.js b/paintplus/frontend/src/js/modules/image/rotate.js new file mode 100644 index 0000000..ad6f35f --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/rotate.js @@ -0,0 +1,180 @@ +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import app from '../../app.js'; + +var instance = null; + +class Image_rotate_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.Dialog = new Dialog_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 76) { + //L - rotate left + this.left(); + event.preventDefault(); + } + }, false); + } + + rotate() { + var _this = this; + + if (config.layer.rotate === null) { + alertify.error('Rotate is not supported on this type of object. Convert to raster?'); + return; + } + + var angles = ['Custom', '0', '90', '180', '270']; + var initial_angle = config.layer.rotate; + + var settings = { + title: 'Rotate', + params: [ + {name: "rotate", title: "Rotate:", value: config.layer.rotate, range: [0, 360]}, + {name: "right_angle", title: "Right angle:", values: angles}, + ], + on_change: function (params, canvas_preview, w, h) { + _this.rotate_handler(params, false); + }, + on_finish: function (params) { + config.layer.rotate = initial_angle; + _this.rotate_handler(params); + }, + on_cancel: function (params) { + config.layer.rotate = initial_angle; + config.need_render = true; + }, + }; + this.Dialog.show(settings); + } + + rotate_handler(data, can_resize = true) { + var value = parseInt(data.rotate); + if (data.right_angle != 'Custom') { + value = parseInt(data.right_angle); + } + + if (value < 0) + value = 360 + value; + if (value >= 360) + value = value - 360; + let new_rotate = value; + + if (can_resize == true) { + app.State.do_action( + new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + rotate: new_rotate + }), + ...this.check_sizes(new_rotate) + ]) + ); + } else { + config.layer.rotate = new_rotate; + config.need_render = true; + } + } + + left() { + let new_rotate = config.layer.rotate; + new_rotate -= 90; + if (new_rotate < 0) + new_rotate = 360 + new_rotate; + + app.State.do_action( + new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + rotate: new_rotate + }), + ...this.check_sizes(new_rotate) + ]) + ); + } + + right() { + let new_rotate = config.layer.rotate; + new_rotate += 90; + if (new_rotate >= 360) + new_rotate = new_rotate - 360; + + app.State.do_action( + new app.Actions.Bundle_action('rotate_layer', 'Rotate Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + rotate: new_rotate + }), + ...this.check_sizes(new_rotate) + ]) + ); + } + + /** + * Makes sure image fits all after rotation + * @returns {array} actions to perform + */ + check_sizes(new_rotate) { + let actions = []; + var w = config.layer.width; + var h = config.layer.height; + + var o = new_rotate * Math.PI / 180; + var new_x = w * Math.abs(Math.cos(o)) + h * Math.abs(Math.sin(o)); + var new_y = w * Math.abs(Math.sin(o)) + h * Math.abs(Math.cos(o)); + + //round values + new_x = Math.ceil(Math.round(new_x * 1000) / 1000); + new_y = Math.ceil(Math.round(new_y * 1000) / 1000); + + if (new_x > config.WIDTH || new_y > config.HEIGHT) { + var dx = 0; + var dy = 0; + let new_width = config.WIDTH; + let new_height = config.HEIGHT; + if (new_x > config.WIDTH) { + dx = Math.ceil(new_x - new_width) / 2; + new_width = new_x; + } + if (new_y > config.HEIGHT) { + dy = Math.ceil(new_y - new_height) / 2; + new_height = new_y; + } + actions.push( + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_layer_action(config.layer.id, { + x: config.layer.x + dx, + y: config.layer.y + dy + }), + new app.Actions.Update_config_action({ + WIDTH: new_width, + HEIGHT: new_height + }), + new app.Actions.Prepare_canvas_action('do') + ); + } + return actions; + } +} + +export default Image_rotate_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/image/selection_effects.js b/paintplus/frontend/src/js/modules/image/selection_effects.js new file mode 100644 index 0000000..724824a --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/selection_effects.js @@ -0,0 +1,353 @@ +/** + * Selection Effects - Apply effects only to the selected area + * Works with any selection tool (Smart Select, Brush Select, Magic Wand, Lasso, Ellipse) + * Useful for CNC depth maps where you want to modify specific objects + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Image_selection_effects_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + /** + * Check if there's a valid selection + */ + hasSelection() { + return window.smartSelectMask && window.smartSelectMask.canvas; + } + + /** + * Invert colors only in the selected area + */ + invert_selection() { + var _this = this; + + if (!this.hasSelection()) { + alertify.error('No selection. Use a selection tool first (Smart Select, Brush Select, Magic Wand, etc.)'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Invert Selection', + preview: true, + params: [ + { + name: "strength", + title: "Strength:", + value: 100, + range: [0, 100] + }, + { + name: "preserve_luminosity", + title: "Preserve Luminosity:", + value: false + } + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_invert(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_invert(params); + }, + }; + this.POP.show(settings); + } + + apply_invert(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var strength = (params.strength || 100) / 100; + var preserveLuminosity = params.preserve_luminosity || false; + + // Get mask data + var maskCanvas = window.smartSelectMask.canvas; + var maskCtx = maskCanvas.getContext('2d'); + + // Scale mask to match current canvas size if needed + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; // 0-1 range + + if (maskValue > 0.5) { // Inside selection + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + + // Invert colors + var newR = 255 - r; + var newG = 255 - g; + var newB = 255 - b; + + if (preserveLuminosity) { + // Calculate original and new luminosity + var oldLum = 0.299 * r + 0.587 * g + 0.114 * b; + var newLum = 0.299 * newR + 0.587 * newG + 0.114 * newB; + + // Adjust to preserve luminosity + if (newLum > 0) { + var ratio = oldLum / newLum; + newR = Math.min(255, newR * ratio); + newG = Math.min(255, newG * ratio); + newB = Math.min(255, newB * ratio); + } + } + + // Apply strength (blend between original and inverted) + data[i] = Math.round(r + (newR - r) * strength); + data[i + 1] = Math.round(g + (newG - g) * strength); + data[i + 2] = Math.round(b + (newB - b) * strength); + } + } + + return imageData; + } + + save_invert(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_invert(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + /** + * Adjust brightness/contrast only in the selected area + */ + adjust_selection() { + var _this = this; + + if (!this.hasSelection()) { + alertify.error('No selection. Use a selection tool first.'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Adjust Selection', + preview: true, + params: [ + {name: "brightness", title: "Brightness:", value: 0, range: [-100, 100]}, + {name: "contrast", title: "Contrast:", value: 0, range: [-100, 100]}, + {name: "gamma", title: "Gamma:", value: 1, range: [0.1, 3], step: 0.1}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_adjust(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_adjust(params); + }, + }; + this.POP.show(settings); + } + + apply_adjust(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var brightness = (params.brightness || 0) * 2.55; + var contrast = (params.contrast || 0) / 100; + var gamma = params.gamma || 1; + + var factor = (1 + contrast); + + // Get mask data + var maskCanvas = window.smartSelectMask.canvas; + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; + + if (maskValue > 0.5) { + for (var c = 0; c < 3; c++) { + var value = data[i + c]; + + // Apply brightness + value += brightness; + + // Apply contrast + value = ((value - 128) * factor) + 128; + + // Apply gamma + value = 255 * Math.pow(value / 255, 1 / gamma); + + data[i + c] = Math.max(0, Math.min(255, Math.round(value))); + } + } + } + + return imageData; + } + + save_adjust(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_adjust(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + /** + * Convert selection to greyscale (useful for depth maps) + */ + greyscale_selection() { + var _this = this; + + if (!this.hasSelection()) { + alertify.error('No selection. Use a selection tool first.'); + return; + } + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var settings = { + title: 'Greyscale Selection', + preview: true, + params: [ + { + name: "method", + title: "Method:", + values: ["Luminosity", "Average", "Lightness"], + value: "Luminosity" + }, + {name: "invert", title: "Invert:", value: false}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.apply_greyscale_selection(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_greyscale_selection(params); + }, + }; + this.POP.show(settings); + } + + apply_greyscale_selection(imageData, params) { + if (!this.hasSelection()) return imageData; + + var data = imageData.data; + var width = imageData.width; + var height = imageData.height; + var method = params.method || "Luminosity"; + var invert = params.invert || false; + + var maskCanvas = window.smartSelectMask.canvas; + var scaledMask = document.createElement('canvas'); + scaledMask.width = width; + scaledMask.height = height; + var scaledCtx = scaledMask.getContext('2d'); + scaledCtx.drawImage(maskCanvas, 0, 0, width, height); + var maskData = scaledCtx.getImageData(0, 0, width, height).data; + + for (var i = 0; i < data.length; i += 4) { + var maskValue = maskData[i] / 255; + + if (maskValue > 0.5) { + var r = data[i]; + var g = data[i + 1]; + var b = data[i + 2]; + var grey; + + switch (method) { + case "Luminosity": + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + break; + case "Average": + grey = (r + g + b) / 3; + break; + case "Lightness": + grey = (Math.max(r, g, b) + Math.min(r, g, b)) / 2; + break; + default: + grey = 0.2126 * r + 0.7152 * g + 0.0722 * b; + } + + if (invert) { + grey = 255 - grey; + } + + grey = Math.max(0, Math.min(255, Math.round(grey))); + + data[i] = grey; + data[i + 1] = grey; + data[i + 2] = grey; + } + } + + return imageData; + } + + save_greyscale_selection(params) { + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.apply_greyscale_selection(img, params); + ctx.putImageData(data, 0, 0); + + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } +} + +export default Image_selection_effects_class; diff --git a/paintplus/frontend/src/js/modules/image/size.js b/paintplus/frontend/src/js/modules/image/size.js new file mode 100644 index 0000000..f942250 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/size.js @@ -0,0 +1,243 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Tools_settings_class from './../tools/settings.js'; +import Helper_class from './../../libs/helpers.js'; +import Pica from './../../../../node_modules/pica/dist/pica.js'; + +// Common print sizes at 300 DPI: [width_px, height_px, display_label] +const PRINT_SIZES = [ + [1500, 2100, '5x7" Portrait'], + [2100, 1500, '5x7" Landscape'], + [2400, 3000, '8x10" Portrait'], + [3000, 2400, '8x10" Landscape'], + [3300, 4200, '11x14" Portrait'], + [4200, 3300, '11x14" Landscape'], + [3600, 4800, '18x24" Portrait 200dpi'], + [4800, 3600, '18x24" Landscape 200dpi'], + [5400, 7200, '18x24" Portrait 300dpi'], + [7200, 5400, '18x24" Landscape 300dpi'], +]; + +class Image_size_class { + + constructor() { + this.Base_gui = new Base_gui_class(); + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + this.pica = Pica(); + this._lastUnits = 'pixels'; + } + + size() { + var _this = this; + var common_dimensions = this.Base_gui.common_dimensions; + var global_units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + var enable_autoresize = this.Tools_settings.get_setting('enable_autoresize'); + + var displayUnits = (global_units === 'inches') ? 'inches' : 'pixels'; + this._lastUnits = displayUnits; + + var resolutions = ['Custom']; + for (var i in common_dimensions) { + var value = common_dimensions[i]; + resolutions.push(value[0] + 'x' + value[1] + ' - ' + value[2]); + } + // Print size presets — WxH format is parsed by existing resolution logic + for (var ps of PRINT_SIZES) { + resolutions.push(ps[0] + 'x' + ps[1] + ' - ' + ps[2] + ' Print'); + } + + var width = this.Helper.get_user_unit(config.WIDTH, displayUnits, resolution); + var height = this.Helper.get_user_unit(config.HEIGHT, displayUnits, resolution); + + var settings = { + title: 'Canvas Size', + params: [ + {name: "units", title: "Units:", value: displayUnits, values: ["pixels", "inches"]}, + {name: "w", title: "Width:", value: width, placeholder: width, comment: displayUnits}, + {name: "h", title: "Height:", value: height, placeholder: height, comment: displayUnits}, + {name: "resolution", title: "Resolution:", values: resolutions}, + {name: "layout", title: "Layout:", value: "Custom", values: ["Custom", "Landscape", "Portrait"]}, + {name: "enable_autoresize", title: "Enable autoresize:", value: enable_autoresize}, + {name: "in_proportion", title: "In proportion:", value: false}, + {name: "resize_image", title: "Resize & crop image:", value: false}, + ], + on_change: function(params) { + _this.units_change_handler(params); + }, + on_finish: function (params) { + _this.size_handler(params); + }, + }; + this.POP.show(settings); + } + + units_change_handler(params) { + var units = params.units; + if (units === this._lastUnits) return; + + this._lastUnits = units; + var resolution = this.Tools_settings.get_setting('resolution'); + + // Persist so Resize and other dialogs open with the same units + const unitShort = {pixels: 'px', inches: '"', centimeters: 'cm', millimetres: 'mm'}; + this.Tools_settings.save_setting('default_units', units); + this.Tools_settings.save_setting('default_units_short', unitShort[units] || units); + + var newWidth = this.Helper.get_user_unit(config.WIDTH, units, resolution); + var newHeight = this.Helper.get_user_unit(config.HEIGHT, units, resolution); + + var wInput = document.getElementById('pop_data_w'); + var hInput = document.getElementById('pop_data_h'); + if (wInput) wInput.value = newWidth; + if (hInput) hInput.value = newHeight; + + // Update the unit label shown next to each field + var wComment = wInput ? wInput.nextElementSibling : null; + var hComment = hInput ? hInput.nextElementSibling : null; + if (wComment && wComment.classList.contains('field_comment')) wComment.textContent = units; + if (hComment && hComment.classList.contains('field_comment')) hComment.textContent = units; + } + + async size_handler(data) { + var width = parseFloat(data.w); + var height = parseFloat(data.h); + var canvasRatio = config.WIDTH / config.HEIGHT; + var units = data.units || this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + if (width < 0) width = 1; + if (height < 0) height = 1; + + this.Tools_settings.save_setting('enable_autoresize', data.enable_autoresize); + + if (isNaN(width) && isNaN(height)) { + alertify.error('Wrong dimensions'); + return; + } + if (isNaN(width)) width = height * canvasRatio; + if (isNaN(height)) height = width / canvasRatio; + + if (data.resolution != 'Custom') { + var dim = data.resolution.split(" "); + dim = dim[0].split("x"); + width = parseInt(dim[0]); + height = parseInt(dim[1]); + + // Don't apply layout swap for print presets (orientation is already encoded) + if (data.layout == 'Portrait' && !data.resolution.includes('Print')) { + var tmp = width; + width = height; + height = tmp; + } + } else { + width = this.Helper.get_internal_unit(width, units, resolution); + height = this.Helper.get_internal_unit(height, units, resolution); + } + + width = parseInt(width); + height = parseInt(height); + + var actions = [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: width, + HEIGHT: height + }), + ]; + + // Proportional layer repositioning (only when not doing full resize+crop) + if (data.in_proportion == true && data.resize_image != true) { + var width_ratio = config.WIDTH / width; + var height_ratio = config.HEIGHT / height; + var maxRatio = Math.max(width_ratio, height_ratio); + + for (var i in config.layers) { + var layer = config.layers[i]; + if (layer.x != null && layer.y != null) { + actions.push(new app.Actions.Update_layer_action(layer.id, { + x: Math.round(layer.x / width_ratio), + y: Math.round(layer.y / height_ratio), + })); + } + if (layer.width != null && layer.height != null) { + actions.push(new app.Actions.Update_layer_action(layer.id, { + width: Math.round(layer.width / maxRatio), + height: Math.round(layer.height / maxRatio), + })); + } + } + } + + // Resize & center-crop image layers to fill the new canvas + if (data.resize_image == true) { + try { + var cropActions = await this.get_resize_crop_actions(width, height); + actions = actions.concat(cropActions); + } catch (error) { + alertify.error('Could not resize image: ' + error.message); + } + } + + actions.push(new app.Actions.Prepare_canvas_action('do')); + + app.State.do_action( + new app.Actions.Bundle_action('set_image_size', 'Set Image Size', actions) + ); + } + + /** + * Generates actions to scale-and-center-crop all image layers to fill targetWidth × targetHeight. + * Uses "cover" scaling: the image is scaled so it fills the target, then cropped from the center. + */ + async get_resize_crop_actions(targetWidth, targetHeight) { + var actions = []; + var srcWidth = config.WIDTH; + var srcHeight = config.HEIGHT; + + var scale = Math.max(targetWidth / srcWidth, targetHeight / srcHeight); + var scaledW = Math.round(srcWidth * scale); + var scaledH = Math.round(srcHeight * scale); + var cropX = Math.round((scaledW - targetWidth) / 2); + var cropY = Math.round((scaledH - targetHeight) / 2); + + for (var i in config.layers) { + var layer = config.layers[i]; + if (layer.type !== 'image') continue; + if (layer.width == null || layer.height == null) continue; + + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var newLayerW = Math.round(layer.width * scale); + var newLayerH = Math.round(layer.height * scale); + + var tmp = document.createElement('canvas'); + tmp.width = newLayerW; + tmp.height = newLayerH; + await this.pica.resize(canvas, tmp, {alpha: true}); + + var newX = Math.round(layer.x * scale) - cropX; + var newY = Math.round(layer.y * scale) - cropY; + + actions.push(new app.Actions.Update_layer_image_action(tmp, layer.id)); + actions.push(new app.Actions.Update_layer_action(layer.id, { + x: newX, + y: newY, + width: newLayerW, + height: newLayerH, + width_original: newLayerW, + height_original: newLayerH, + })); + } + + return actions; + } +} + +export default Image_size_class; diff --git a/paintplus/frontend/src/js/modules/image/translate.js b/paintplus/frontend/src/js/modules/image/translate.js new file mode 100644 index 0000000..4db6bf4 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/translate.js @@ -0,0 +1,47 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Tools_settings_class from './../tools/settings.js'; +import Helper_class from './../../libs/helpers.js'; + +class Image_translate_class { + + constructor() { + this.POP = new Dialog_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + } + + translate() { + var _this = this; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var pos_x = this.Helper.get_user_unit(config.layer.x, units, resolution); + var pos_y = this.Helper.get_user_unit(config.layer.y, units, resolution); + + var settings = { + title: 'Translate', + params: [ + {name: "x", title: "X position:", value: pos_x}, + {name: "y", title: "Y position:", value: pos_y}, + ], + on_finish: function (params) { + var pos_x = _this.Helper.get_internal_unit(params.x, units, resolution); + var pos_y = _this.Helper.get_internal_unit(params.y, units, resolution); + + app.State.do_action( + new app.Actions.Bundle_action('translate_layer', 'Translate Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: pos_x, + y: pos_y, + }) + ]) + ); + }, + }; + this.POP.show(settings); + } +} + +export default Image_translate_class; diff --git a/paintplus/frontend/src/js/modules/image/trim.js b/paintplus/frontend/src/js/modules/image/trim.js new file mode 100644 index 0000000..23f6113 --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/trim.js @@ -0,0 +1,305 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Image_trim_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.Helper = new Helper_class(); + this.Dialog = new Dialog_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 84) { + //trim + this.trim(); + event.preventDefault(); + } + }, false); + } + + trim() { + var _this = this; + var removeWhiteColor = false; + if(config.TRANSPARENCY == false) + removeWhiteColor = true; + + var settings = { + title: 'Trim', + params: [ + {name: "trim_layer", title: "Trim layer:", value: true}, + {name: "trim_all", title: "Trim borders:", value: true}, + {name: "power", title: "Power:", value: 0, max: 255}, + {name: "remove_white", title: "Trim white color?", value: removeWhiteColor}, + ], + on_finish: async (params) => { + if (params.trim_layer == true) { + //first trim + let actions = []; + actions = actions.concat(this.trim_layer(config.layer.id, params.remove_white, params.power)); + await app.State.do_action( + new app.Actions.Bundle_action('trim_layers', 'Trim Layers', actions) + ); + } + if (params.trim_all == true) { + //second trim + let actions = []; + actions = actions.concat(_this.trim_all(params.remove_white, params.power)); + app.State.do_action( + new app.Actions.Bundle_action('trim_layers', 'Trim Layers', actions) + ); + } + }, + }; + this.Dialog.show(settings); + } + + /** + * removes empty (white/transparent) area from top, right, bottom and left sides + * This affects layer data + * + * @param layer_id + * @param removeWhiteColor + * @param {int} power + */ + trim_layer(layer_id, removeWhiteColor = false, power = 0) { + var layer = this.Base_layers.get_layer(layer_id); + + if (layer.type != 'image') { + alertify.error('Skip - layer must be image.'); + return false; + } + + var trim = this.get_trim_info(layer_id, removeWhiteColor, power); + trim = trim.relative; + + //if image was stretched + var width_ratio = (layer.width / layer.width_original); + var height_ratio = (layer.height / layer.height_original); + + //create smaller canvas + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = trim.width / width_ratio; + canvas.height = trim.height / height_ratio; + + //cut required part + ctx.translate(-trim.left / width_ratio, -trim.top / height_ratio); + canvas.getContext("2d").drawImage(layer.link, 0, 0); + ctx.translate(0, 0); + + return [ + new app.Actions.Update_layer_image_action(canvas, layer.id), + new app.Actions.Update_layer_action(layer.id, { + x: layer.x + trim.left, + y: layer.y + trim.top, + width: Math.ceil(canvas.width * width_ratio), + height: Math.ceil(canvas.height * height_ratio), + width_original: canvas.width, + height_original: canvas.height + }) + ]; + } + + /** + * change canvas size, so there is no empty (white/transparent) areas on top, right, bottom and left sides + * this affect canvas size and all layers positions + * + * @param removeWhiteColor + * @param {int} power + */ + trim_all(removeWhiteColor = false, power = 0) { + let actions = []; + + var all_top = config.HEIGHT; + var all_left = config.WIDTH; + var all_bottom = config.HEIGHT; + var all_right = config.WIDTH; + + if (removeWhiteColor == undefined) { + removeWhiteColor = false; + if (config.TRANSPARENCY == false) { + removeWhiteColor = true; + } + } + + //collect info + for (let i = 0; i < config.layers.length; i++) { + let layer = config.layers[i]; + + if (layer.width == null || layer.height == null || layer.x == null || layer.y == null) { + //layer without dimensions + const trim_info = this.get_trim_info(layer.id, removeWhiteColor, power); + + all_top = Math.min(all_top, trim_info.top); + all_left = Math.min(all_left, trim_info.left); + all_bottom = Math.min(all_bottom, trim_info.bottom); + all_right = Math.min(all_right, trim_info.right); + } + else{ + all_top = Math.min(all_top, layer.y); + all_left = Math.min(all_left, layer.x); + all_bottom = Math.min(all_bottom, config.HEIGHT - layer.height - layer.y); + all_right = Math.min(all_right, config.WIDTH - layer.width - layer.x); + } + } + + //move every layer + for (let i = 0; i < config.layers.length; i++) { + let layer = config.layers[i]; + if (layer.x == null || layer.y == null || layer.type == null) + continue; + + actions.push( + new app.Actions.Update_layer_action(layer.id, { + x: layer.x - all_left, + y: layer.y - all_top + }) + ); + } + + //resize + actions.push( + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: Math.max(1, config.WIDTH - all_left - all_right), + HEIGHT: Math.max(1, config.HEIGHT - all_top - all_bottom) + }), + new app.Actions.Prepare_canvas_action('do') + ); + return actions; + } + + /** + * get painted area coords + * + * @param {int} layer_id + * @param {boolean} trim_white + * @param {int} power + * @returns {object} keys: top, left, bottom, right, width, height, relative + */ + get_trim_info(layer_id, trim_white, power) { + if (trim_white == undefined) { + trim_white = false; + if (config.TRANSPARENCY == false) { + trim_white = true; + } + } + if (power == undefined) { + power = 0; + } + var layer = this.Base_layers.get_layer(layer_id); + + var canvas = this.Base_layers.convert_layer_to_canvas(layer_id, null, false); + var ctx = canvas.getContext("2d"); + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var imgData = img.data; + + var top = 0; + var left = 0; + var bottom = 0; + var right = 0; + + //check top + main1: + for (var y = 0; y < img.height; y++) { + for (var x = 0; x < img.width; x++) { + var k = ((y * (img.width * 4)) + (x * 4)); + if (imgData[k + 3] <= power) + continue; //transparent + if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power + && imgData[k + 2] >= 255 - power) + continue; //white + break main1; + } + top++; + } + //check left + main2: + for (var x = 0; x < img.width; x++) { + for (var y = 0; y < img.height; y++) { + var k = ((y * (img.width * 4)) + (x * 4)); + if (imgData[k + 3] <= power) + continue; //transparent + if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power + && imgData[k + 2] >= 255 - power) + continue; //white + break main2; + } + left++; + } + //check bottom + main3: + for (var y = img.height - 1; y >= 0; y--) { + for (var x = img.width - 1; x >= 0; x--) { + var k = ((y * (img.width * 4)) + (x * 4)); + if (imgData[k + 3] <= power) + continue; //transparent + if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power + && imgData[k + 2] >= 255 - power) + continue; //white + break main3; + } + bottom++; + } + //check right + main4: + for (var x = img.width - 1; x >= 0; x--) { + for (var y = img.height - 1; y >= 0; y--) { + var k = ((y * (img.width * 4)) + (x * 4)); + if (imgData[k + 3] <= power) + continue; //transparent + if (trim_white == true && imgData[k] >= 255 - power && imgData[k + 1] >= 255 - power + && imgData[k + 2] >= 255 - power) + continue; //white + break main4; + } + right++; + } + + var top_rel = top - layer.y; + var left_rel = left - layer.x; + var bottom_rel = bottom - (config.HEIGHT - layer.y - layer.height); + var right_rel = right - (config.WIDTH - layer.x - layer.width); + + return { + top: top, + left: left, + bottom: bottom, + right: right, + width: canvas.width - left - right, + height: canvas.height - top - bottom, + relative: { + top: top_rel, + left: left_rel, + bottom: bottom_rel, + right: right_rel, + width: canvas.width - left - right, + height: canvas.height - top - bottom, + }, + }; + } +} + +export default Image_trim_class; diff --git a/paintplus/frontend/src/js/modules/image/upscale.js b/paintplus/frontend/src/js/modules/image/upscale.js new file mode 100644 index 0000000..9947a1f --- /dev/null +++ b/paintplus/frontend/src/js/modules/image/upscale.js @@ -0,0 +1,306 @@ +/** + * Upscale — increase image resolution. + * Fetches available methods from /api/print/upscale/available on first open. + * Auto-selects the recommended method; user can override. + * If no AI upscaler is found, polls /api/print/upscale/install-status while + * the backend auto-installs Real-ESRGAN NCNN Vulkan, then refreshes and continues. + * + * Menu target: image/upscale.upscale + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { showProgress, updateProgress, hideProgress } from './../../libs/progress_overlay.js'; + +var instance = null; + +const METHOD_LABELS = { + auto: 'Auto (best available)', + realesrgan_pytorch: 'Real-ESRGAN — PyTorch', + realesrgan_ncnn: 'Real-ESRGAN — NCNN Vulkan', + lanczos: 'Lanczos (fast, no AI)', +}; + +class Image_upscale_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + this._caps = null; + } + + async upscale() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + // If a previous caps fetch showed no AI upscaler, check install progress + var caps = await this._fetchCaps(); + if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) { + await this._waitForInstall(caps); + // Re-fetch caps after install + this._caps = null; + caps = await this._fetchCaps(); + } + + this._showDialog(caps); + } + + _showDialog(caps) { + var W = config.layer.width_original; + var H = config.layer.height_original; + + var available = ['auto', ...caps.methods]; + var methodValues = [...new Set(available)]; + + var methodLabels = methodValues.map(m => { + var label = METHOD_LABELS[m] || m; + if (m === 'auto') { + label = `Auto → ${caps.recommended_label}`; + } else if (m === caps.recommended && m !== 'auto') { + label += ' ★'; + } + return label; + }); + + var deviceNote = ''; + if (caps.realesrgan_pytorch) { + var dev = caps.realesrgan_pytorch_device; + var devLabel = dev === 'cuda' ? 'CUDA GPU' + : dev === 'mps' ? 'Apple Silicon' + : 'CPU (slow — ~1–3 min for large images)'; + deviceNote += `PyTorch: ${devLabel}. `; + } + if (caps.realesrgan_ncnn) { + deviceNote += 'NCNN Vulkan binary found. '; + } + if (!caps.realesrgan_pytorch && !caps.realesrgan_ncnn) { + var installState = (caps.ncnn_install_status || {}).state; + if (installState === 'skipped') { + deviceNote = 'Headless server — no Vulkan GPU. Lanczos only. ' + + 'Install Real-ESRGAN PyTorch for AI quality on CPU.'; + } else { + deviceNote = 'No AI upscaler available — Lanczos only.'; + } + } + + var _this = this; + + this.Dialog.show({ + title: 'Upscale Image', + params: [ + { + title: '', + html: `
    + Current: ${W}×${H}px
    + ${deviceNote} +
    `, + }, + { + name: 'scale', + title: 'Scale factor:', + value: '2×', + values: ['1.5×', '2×', '3×', '4×'], + type: 'select', + }, + { + name: 'method', + title: 'Method:', + value: methodLabels[0], + values: methodLabels, + type: 'select', + }, + { + name: 'new_layer', + title: 'Result as new layer (keep original):', + value: false, + }, + ], + on_finish: async function (params) { + var labelIdx = methodLabels.indexOf(params.method); + var methodKey = labelIdx >= 0 ? methodValues[labelIdx] : 'auto'; + var scale = parseFloat(params.scale); + await _this._run(scale, methodKey, params.new_layer); + }, + }); + } + + /** + * Poll install-status until done/failed/skipped, showing a progress bar. + * On headless machines the server sets state=skipped immediately — no wait. + */ + async _waitForInstall(caps) { + var installStatus = caps.ncnn_install_status || {}; + var terminalStates = ['done', 'failed', 'skipped']; + if (terminalStates.includes(installStatus.state)) { + if (installStatus.state === 'skipped') { + // Headless — just proceed, dialog will show Lanczos or PyTorch CPU + alertify.message(installStatus.message || 'No Vulkan GPU — using CPU upscaler.', 4); + } + return; + } + + return new Promise((resolve) => { + alertify.message( + `
    Installing Real-ESRGAN AI upscaler…
    + + 0%
    `, + 0 + ); + + var poll = setInterval(async () => { + try { + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/upscale/install-status`); + if (!r.ok) return; + var s = await r.json(); + + var bar = document.getElementById('esrgan-install-progress'); + var pct = document.getElementById('esrgan-install-pct'); + if (bar) bar.value = s.progress || 0; + if (pct) pct.textContent = `${s.progress || 0}%`; + + if (s.state === 'done') { + clearInterval(poll); + alertify.dismissAll(); + alertify.success('Real-ESRGAN NCNN installed.'); + resolve(); + } else if (s.state === 'skipped') { + clearInterval(poll); + alertify.dismissAll(); + alertify.message(s.message || 'No Vulkan GPU — using CPU upscaler.', 4); + resolve(); + } else if (s.state === 'failed') { + clearInterval(poll); + alertify.dismissAll(); + alertify.warning('AI upscaler install failed — using Lanczos.'); + resolve(); + } + } catch { /* network hiccup, keep polling */ } + }, 1500); + }); + } + + async _fetchCaps() { + if (this._caps) return this._caps; + try { + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/upscale/available`); + if (r.ok) { + this._caps = await r.json(); + } + } catch { /* ignore */ } + + if (!this._caps) { + this._caps = { + lanczos: true, + realesrgan_pytorch: false, + realesrgan_ncnn: false, + recommended: 'lanczos', + recommended_label: 'Lanczos', + methods: ['lanczos'], + ncnn_install_status: { state: 'idle', progress: 0 }, + }; + } + return this._caps; + } + + async _run(scale, method, newLayer) { + if (this.isProcessing) return; + this.isProcessing = true; + + var caps = this._caps || {}; + var methodLabel = method === 'auto' + ? `Auto (${caps.recommended_label || 'best available'})` + : (METHOD_LABELS[method] || method); + + var isAI = method !== 'lanczos'; + showProgress( + `Upscaling ${scale}× with ${methodLabel}…` + + (isAI ? '\nAI is reconstructing detail — this may take 30–120 seconds.' : ''), + isAI ? 90 : 10 + ); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/upscale`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageB64, scale, method }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Upscale failed'); + } + var result = await r.json(); + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + var usedLabel = result.method.replace('realesrgan_pytorch_', 'ESRGAN/') + .replace('realesrgan_ncnn', 'ESRGAN/NCNN'); + + if (newLayer) { + app.State.do_action( + new app.Actions.Bundle_action('upscale_layer', 'Upscale', [ + new app.Actions.Insert_layer_action({ + name: `${scale}× ${usedLabel}`, + type: 'image', + data: img.src, + x: 0, y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + width_original: img.naturalWidth, + height_original: img.naturalHeight, + }) + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('upscale', 'Upscale', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + } + + hideProgress(); + alertify.success( + `${result.output.width}×${result.output.height}px · ${usedLabel}` + ); + this.isProcessing = false; + }; + img.onerror = () => { + hideProgress(); + alertify.error('Failed to load upscaled image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + hideProgress(); + alertify.error('Upscale failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Image_upscale_class; diff --git a/paintplus/frontend/src/js/modules/layer/align.js b/paintplus/frontend/src/js/modules/layer/align.js new file mode 100644 index 0000000..9e22fc2 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/align.js @@ -0,0 +1,114 @@ +/** + * Layer Alignment — align the active layer (or multiple selected layers) to the canvas. + * Operations: center H, center V, center both, align left/right/top/bottom, distribute. + * Shows as a compact floating toolbar. + * + * Menu target: layer/align.align + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +const BUTTONS = [ + { id: 'ch', label: '⬌', title: 'Center horizontally on canvas' }, + { id: 'cv', label: '⬍', title: 'Center vertically on canvas' }, + { id: 'cc', label: '⊕', title: 'Center on canvas' }, + { id: 'sep', label: '|', title: '', sep: true }, + { id: 'al', label: '⇤', title: 'Align left edge to canvas' }, + { id: 'ar', label: '⇥', title: 'Align right edge to canvas' }, + { id: 'at', label: '⇡', title: 'Align top edge to canvas' }, + { id: 'ab', label: '⇣', title: 'Align bottom edge to canvas' }, +]; + +class Layer_align_class { + constructor() { + if (instance) return instance; + instance = this; + this._panel = null; + } + + align() { + if (this._panel) { this._removePanel(); return; } + this._mountPanel(); + } + + _mountPanel() { + this._removePanel(); + const panel = document.createElement('div'); + panel.id = 'align_panel'; + Object.assign(panel.style, { + position: 'fixed', + top: '60px', + left: '50%', + transform: 'translateX(-50%)', + background: '#1a1a1a', + border: '1px solid #3a3a3a', + borderRadius: '10px', + padding: '7px 10px', + display: 'flex', + alignItems: 'center', + gap: '4px', + zIndex: '8889', + boxShadow: '0 4px 16px rgba(0,0,0,0.5)', + fontFamily: 'sans-serif', + userSelect: 'none', + }); + + const btnHtml = BUTTONS.map(b => { + if (b.sep) return ``; + return ``; + }).join(''); + + panel.innerHTML = ` + Align: + ${btnHtml} + ×`; + + document.body.appendChild(panel); + this._panel = panel; + + panel.querySelector('#align-close').addEventListener('click', () => this._removePanel()); + panel.querySelectorAll('[data-align]').forEach(btn => { + btn.addEventListener('click', () => this._doAlign(btn.dataset.align)); + }); + } + + _doAlign(op) { + const layer = config.layer; + if (!layer) { alertify.error('Select a layer first.'); return; } + + const cw = config.WIDTH; + const ch = config.HEIGHT; + const lw = layer.width; + const lh = layer.height; + + let newX = layer.x; + let newY = layer.y; + + if (op === 'ch' || op === 'cc') newX = Math.round((cw - lw) / 2); + if (op === 'cv' || op === 'cc') newY = Math.round((ch - lh) / 2); + if (op === 'al') newX = 0; + if (op === 'ar') newX = cw - lw; + if (op === 'at') newY = 0; + if (op === 'ab') newY = ch - lh; + + app.State.do_action( + new app.Actions.Update_layer_action(layer.id, { x: newX, y: newY }) + ); + } + + _removePanel() { + if (this._panel) { this._panel.remove(); this._panel = null; } + } +} + +export default Layer_align_class; diff --git a/paintplus/frontend/src/js/modules/layer/clear.js b/paintplus/frontend/src/js/modules/layer/clear.js new file mode 100644 index 0000000..dbc758c --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/clear.js @@ -0,0 +1,19 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Layer_clear_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + clear() { + return app.State.do_action( + new app.Actions.Clear_layer_action(config.layer.id) + ); + } + +} + +export default Layer_clear_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/composition.js b/paintplus/frontend/src/js/modules/layer/composition.js new file mode 100644 index 0000000..c031e4c --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/composition.js @@ -0,0 +1,86 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Base_gui_class from "../../core/base-gui.js"; + +class Layer_composition_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_gui_class = new Base_gui_class(); + } + + composition() { + var compositions = [ + "-- Default --", + "color", + "color-burn", + "color-dodge", + "copy", + "darken", + "darker", + "destination-atop", + "destination-in", + "destination-out", + "destination-over", + "difference", + "exclusion", + "hard-light", + "hue", + "lighten", + "lighter", + "luminosity", + "multiply", + "overlay", + "saturation", + "screen", + "soft-light", + "source-atop", + "source-in", + "source-out", + "source-over", + "xor", + ]; + + var initial_composition = config.layer.composition; + var _this = this; + + var settings = { + title: 'Composition', + //preview: true, + params: [ + {name: "composition", title: "Composition:", value: config.layer.composition, values: compositions}, + ], + on_change: function (params, canvas_preview, w, h) { + //redraw preview + if (params.composition == '-- Default --') { + params.composition = 'source-over'; + } + config.layer.composition = params.composition; + config.need_render = true; + _this.Base_gui_class.GUI_layers.render_layers(); + }, + on_finish: function (params) { + config.layer.composition = initial_composition; + if (params.composition == '-- Default --') { + params.composition = 'source-over'; + } + app.State.do_action( + new app.Actions.Bundle_action('change_composition', 'Change Composition', [ + new app.Actions.Update_layer_action(config.layer.id, { + composition: params.composition + }) + ]) + ); + }, + on_cancel: function (params) { + config.layer.composition = initial_composition; + config.need_render = true; + _this.Base_gui_class.GUI_layers.render_layers(); + }, + }; + this.POP.show(settings); + } +} + +export default Layer_composition_class; diff --git a/paintplus/frontend/src/js/modules/layer/delete.js b/paintplus/frontend/src/js/modules/layer/delete.js new file mode 100644 index 0000000..c4a444f --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/delete.js @@ -0,0 +1,19 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Layer_delete_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + delete() { + app.State.do_action( + new app.Actions.Delete_layer_action(config.layer.id) + ); + } + +} + +export default Layer_delete_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/differences.js b/paintplus/frontend/src/js/modules/layer/differences.js new file mode 100644 index 0000000..755808d --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/differences.js @@ -0,0 +1,105 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Layer_differences_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + differences() { + var _this = this; + if (this.Base_layers.find_previous(config.layer.id) == null) { + alertify.error('There are no layers behind.'); + return false; + } + + var settings = { + title: 'Differences', + preview: true, + params: [ + {name: "sensitivity", title: "Sensitivity:", value: "0", range: [0, 255]}, + ], + on_change: function (params, canvas_preview, w, h) { + _this.calc_differences(params.sensitivity, canvas_preview, w, h); + }, + on_finish: function (params) { + _this.calc_differences(params.sensitivity); + }, + }; + this.POP.show(settings); + } + + calc_differences(sensitivity, canvas_preview, w, h) { + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + var ctx = canvas.getContext("2d"); + + //get source data + this.Base_layers.render_object(ctx, config.layer); + var imgData1 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT).data; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + //get target data + var next_layer = this.Base_layers.find_previous(config.layer.id); + this.Base_layers.render_object(ctx, next_layer); + var imgData2 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT).data; + + //prepare background + ctx.rect(0, 0, config.WIDTH, config.HEIGHT); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + + //generate diff + var img3 = ctx.getImageData(0, 0, config.WIDTH, config.HEIGHT); + var imgData3 = img3.data; + for (var xx = 0; xx < config.WIDTH; xx++) { + for (var yy = 0; yy < config.HEIGHT; yy++) { + var x = (xx + yy * config.WIDTH) * 4; + + if (Math.abs(imgData1[x] - imgData2[x]) > sensitivity + || Math.abs(imgData1[x + 1] - imgData2[x + 1]) > sensitivity + || Math.abs(imgData1[x + 2] - imgData2[x + 2]) > sensitivity + || Math.abs(imgData1[x + 3] - imgData2[x + 3]) > sensitivity) { + imgData3[x] = 255; + imgData3[x + 1] = 0; + imgData3[x + 2] = 0; + imgData3[x + 3] = 255; + } + } + } + ctx.putImageData(img3, 0, 0); + + //show + if (canvas_preview == undefined) { + //main + var params = []; + params.type = 'image'; + params.name = 'Differences'; + params.data = canvas.toDataURL("image/png"); + app.State.do_action( + new app.Actions.Insert_layer_action(params) + ); + } + else { + //preview + canvas_preview.save(); + canvas_preview.scale(w / config.WIDTH, h / config.HEIGHT); + canvas_preview.drawImage(canvas, 0, 0); + canvas_preview.restore(); + } + + canvas.width = 1; + canvas.height = 1; + } + +} + +export default Layer_differences_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/duplicate.js b/paintplus/frontend/src/js/modules/layer/duplicate.js new file mode 100644 index 0000000..59876b1 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/duplicate.js @@ -0,0 +1,78 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; + +var instance = null; + +class Layer_duplicate_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 68) { + //D - duplicate + this.duplicate(); + event.preventDefault(); + } + }, false); + } + + duplicate() { + var params = JSON.parse(JSON.stringify(config.layer)); + delete params.id; + delete params.order; + + //generate name + var name_number = params.name.match(/^(.*) #([0-9]+)$/); + if(name_number == null){ + //first duplicate + params.name = params.name + " #2"; + } + else{ + //nth duplicate - name like "query #17" + params.name = name_number[1] + " #" + (parseInt(name_number[2]) + 1) + } + + if(params.x != 0 || params.y != 0 || params.width != config.WIDTH || params.height != config.HEIGHT){ + params.x += 10; + params.y += 10; + } + + for (var i in params) { + //remove private attributes + if (i[0] == '_') + delete params[i]; + } + + if (params.type == 'image') { + //image + params.link = config.layer.link.cloneNode(true); + } + + app.State.do_action( + new app.Actions.Bundle_action('duplicate_layer', 'Duplicate Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + } + +} + +export default Layer_duplicate_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/flatten.js b/paintplus/frontend/src/js/modules/layer/flatten.js new file mode 100644 index 0000000..cf148f3 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/flatten.js @@ -0,0 +1,56 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Layer_flatten_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + flatten() { + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + var ctx = canvas.getContext("2d"); + + var layers_sorted = this.Base_layers.get_sorted_layers(); + + //paint layers + for (var i = layers_sorted.length - 1; i >= 0; i--) { + var layer = layers_sorted[i]; + + ctx.globalAlpha = layer.opacity / 100; + ctx.globalCompositeOperation = layer.composition; + + this.Base_layers.render_object(ctx, layer); + } + + //create requested layer + var params = []; + params.type = 'image'; + params.name = 'Merged'; + params.data = canvas.toDataURL("image/png"); + + //remove rest of layers + let delete_actions = []; + for (var i = config.layers.length - 1; i >= 0; i--) { + delete_actions.push(new app.Actions.Delete_layer_action(config.layers[i].id)); + } + // Run actions + app.State.do_action( + new app.Actions.Bundle_action('flatten_image', 'Flatten Image', [ + new app.Actions.Insert_layer_action(params), + ...delete_actions + ]) + ); + + canvas.width = 1; + canvas.height = 1; + } + +} + +export default Layer_flatten_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/merge.js b/paintplus/frontend/src/js/modules/layer/merge.js new file mode 100644 index 0000000..82f093e --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/merge.js @@ -0,0 +1,59 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Layer_merge_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + merge() { + if (this.Base_layers.find_previous(config.layer.id) == null) { + alertify.error('There are no layers behind.'); + return false; + } + + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + var ctx = canvas.getContext("2d"); + + //first layer + var previous_layer = this.Base_layers.find_previous(config.layer.id); + var previous_id = previous_layer.id; + ctx.globalAlpha = previous_layer.opacity / 100; + ctx.globalCompositeOperation = previous_layer.composition; + this.Base_layers.render_object(ctx, previous_layer); + + //second layer + var current_id = config.layer.id; + var current_order = config.layer.order; + ctx.globalAlpha = config.layer.opacity / 100; + ctx.globalCompositeOperation = config.layer.composition; + this.Base_layers.render_object(ctx, config.layer); + + //create requested layer + var params = []; + params.type = 'image'; + params.name = config.layer.name + ' + merged'; + params.order = current_order; + params.data = canvas.toDataURL("image/png"); + app.State.do_action( + new app.Actions.Bundle_action('merge_layers', 'Merge Layers', [ + new app.Actions.Insert_layer_action(params), + new app.Actions.Delete_layer_action(current_id), + new app.Actions.Delete_layer_action(previous_id) + ]) + ); + + //free canvas data + canvas.width = 1; + canvas.height = 1; + } + +} + +export default Layer_merge_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/move.js b/paintplus/frontend/src/js/modules/layer/move.js new file mode 100644 index 0000000..f27e3a0 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/move.js @@ -0,0 +1,24 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Layer_move_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + up() { + app.State.do_action( + new app.Actions.Reorder_layer_action(config.layer.id, 1) + ); + } + + down() { + app.State.do_action( + new app.Actions.Reorder_layer_action(config.layer.id, -1) + ); + } +} + +export default Layer_move_class; diff --git a/paintplus/frontend/src/js/modules/layer/new.js b/paintplus/frontend/src/js/modules/layer/new.js new file mode 100644 index 0000000..54f0207 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/new.js @@ -0,0 +1,97 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import GUI_tools_class from './../../core/gui/gui-tools.js'; +import Base_selection_class from './../../core/base-selection.js'; +import Selection_class from './../../tools/selection.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Layer_new_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + this.Selection = new Selection_class(); + this.Base_selection = new Base_selection_class(this.Base_layers.ctx); + this.GUI_tools = new GUI_tools_class(); + this.Helper = new Helper_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 78 && event.ctrlKey != true && event.metaKey != true) { + //N + this.new(); + } + }, false); + } + + new() { + app.State.do_action( + new app.Actions.Insert_layer_action() + ); + } + + new_selection() { + var selection = this.Base_selection.get_selection(); + var layer = config.layer; + + if (selection.width === null || config.layer.type != 'image') { + alertify.error('Empty selection or type not image.'); + return; + } + if (config.TOOL.name != 'selection') { + alertify.error('Empty selection or type not image.'); + return; + } + + //if image was stretched + var width_ratio = (layer.width / layer.width_original); + var height_ratio = (layer.height / layer.height_original); + + var left = selection.x - layer.x; + var top = selection.y - layer.y; + + //adapt to origin size + selection.width = selection.width / width_ratio; + selection.height = selection.height / height_ratio; + + //create new layer + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = Math.round(selection.width); + canvas.height = Math.round(selection.height); + + ctx.translate(-left / width_ratio, -top / height_ratio); + ctx.drawImage(config.layer.link, 0, 0); + ctx.translate(0, 0); + + //register it + var params = { + x: Math.round(selection.x), + y: Math.round(selection.y), + width: Math.round(selection.width * width_ratio), + height: Math.round(selection.height * height_ratio), + width_original: Math.round(selection.width), + height_original: Math.round(selection.height), + type: 'image', + data: canvas.toDataURL("image/png"), + }; + app.State.do_action( + new app.Actions.Bundle_action('new_layer', 'New Layer', [ + new app.Actions.Insert_layer_action(params, false), + ...this.Selection.on_leave(), + new app.Actions.Activate_tool_action('select') + ]) + ); + } + +} + +export default Layer_new_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/layer/raster.js b/paintplus/frontend/src/js/modules/layer/raster.js new file mode 100644 index 0000000..05f0ef4 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/raster.js @@ -0,0 +1,38 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Layer_raster_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + raster() { + var canvas = this.Base_layers.convert_layer_to_canvas(); + var current_layer = config.layer; + var current_id = current_layer.id; + + //show + var params = { + type: 'image', + name: config.layer.name + ' + raster', + data: canvas.toDataURL("image/png"), + x: parseInt(canvas.dataset.x), + y: parseInt(canvas.dataset.y), + width: canvas.width, + height: canvas.height, + opacity: current_layer.opacity, + }; + app.State.do_action( + new app.Actions.Bundle_action('convert_to_raster', 'Convert to Raster', [ + new app.Actions.Insert_layer_action(params, false), + new app.Actions.Delete_layer_action(current_id) + ]) + ); + } + +} + +export default Layer_raster_class; diff --git a/paintplus/frontend/src/js/modules/layer/rename.js b/paintplus/frontend/src/js/modules/layer/rename.js new file mode 100644 index 0000000..0d8f217 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/rename.js @@ -0,0 +1,55 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; + +class Layer_rename_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + } + + rename(id = null) { + var _this = this; + + var name_ = this.Helper.escapeHtml(config.layer.name); + + var settings = { + title: 'Rename', + params: [ + {name: "name", title: "Name:", value: name_}, + ], + on_load: function () { + document.querySelector('#pop_data_name').select(); + }, + on_finish: function (params) { + app.State.do_action( + new app.Actions.Bundle_action('rename_layer', 'Rename Layer', [ + new app.Actions.Refresh_layers_gui_action('undo'), + new app.Actions.Update_layer_action(id || config.layer.id, { + name: _this.validate_name(params.name) + }), + new app.Actions.Refresh_layers_gui_action('do') + ]) + ); + }, + }; + this.POP.show(settings); + } + + validate_name(text) { + text = text + .replace(/&/g, "-") + .replace(//g, "-") + .replace(/"/g, "-") + .replace(/'/g, "-"); + + return text; + } +} + +export default Layer_rename_class; diff --git a/paintplus/frontend/src/js/modules/layer/scale.js b/paintplus/frontend/src/js/modules/layer/scale.js new file mode 100644 index 0000000..34a8b50 --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/scale.js @@ -0,0 +1,174 @@ +/** + * Layer Scale module - Scale individual layers (like GIMP's Scale Layer) + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Pica from './../../../../node_modules/pica/dist/pica.js'; + +var instance = null; + +class Layer_scale_class { + + constructor() { + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.pica = Pica(); + } + + scale() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('Please convert layer to raster first (Layer > Raster)'); + return; + } + + var currentWidth = config.layer.width; + var currentHeight = config.layer.height; + var aspectRatio = currentWidth / currentHeight; + + var settings = { + title: 'Scale Layer', + params: [ + {name: "width", title: "Width:", value: currentWidth, placeholder: currentWidth}, + {name: "height", title: "Height:", value: currentHeight, placeholder: currentHeight}, + {name: "width_percent", title: "Width %:", value: 100, placeholder: 100}, + {name: "height_percent", title: "Height %:", value: 100, placeholder: 100}, + {name: "maintain_aspect", title: "Maintain Aspect Ratio:", value: true}, + {name: "interpolation", title: "Interpolation:", values: ["Lanczos (Best)", "Bilinear", "Nearest"]}, + ], + on_change: function(params) { + // Auto-adjust to maintain aspect ratio if enabled + if (params.maintain_aspect) { + var widthInput = document.getElementById("pop_data_width"); + var heightInput = document.getElementById("pop_data_height"); + var widthPercentInput = document.getElementById("pop_data_width_percent"); + var heightPercentInput = document.getElementById("pop_data_height_percent"); + + // This is simplified - in practice you'd track which field changed + } + }, + on_finish: function (params) { + _this.do_scale(params); + }, + }; + this.POP.show(settings); + } + + async do_scale(params) { + var layer = config.layer; + + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var currentWidth = layer.width; + var currentHeight = layer.height; + + // Calculate new dimensions + var newWidth, newHeight; + + if (params.width && params.width != currentWidth) { + newWidth = parseInt(params.width); + if (params.maintain_aspect) { + newHeight = Math.round(newWidth / (currentWidth / currentHeight)); + } else { + newHeight = params.height ? parseInt(params.height) : currentHeight; + } + } else if (params.height && params.height != currentHeight) { + newHeight = parseInt(params.height); + if (params.maintain_aspect) { + newWidth = Math.round(newHeight * (currentWidth / currentHeight)); + } else { + newWidth = params.width ? parseInt(params.width) : currentWidth; + } + } else if (params.width_percent && params.width_percent != 100) { + newWidth = Math.round(currentWidth * params.width_percent / 100); + if (params.maintain_aspect) { + newHeight = Math.round(currentHeight * params.width_percent / 100); + } else { + newHeight = Math.round(currentHeight * (params.height_percent || 100) / 100); + } + } else if (params.height_percent && params.height_percent != 100) { + newHeight = Math.round(currentHeight * params.height_percent / 100); + if (params.maintain_aspect) { + newWidth = Math.round(currentWidth * params.height_percent / 100); + } else { + newWidth = Math.round(currentWidth * (params.width_percent || 100) / 100); + } + } else { + newWidth = parseInt(params.width) || currentWidth; + newHeight = parseInt(params.height) || currentHeight; + } + + if (newWidth <= 0 || newHeight <= 0) { + alertify.error('Invalid dimensions'); + return; + } + + if (newWidth === currentWidth && newHeight === currentHeight) { + alertify.warning('No change in size'); + return; + } + + // Get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(layer.id, true, false); + var ctx = canvas.getContext("2d"); + + // Create destination canvas + var destCanvas = document.createElement('canvas'); + destCanvas.width = newWidth; + destCanvas.height = newHeight; + var destCtx = destCanvas.getContext('2d'); + + // Perform resize based on interpolation method + if (params.interpolation === "Lanczos (Best)") { + await this.pica.resize(canvas, destCanvas, { alpha: true }); + } else if (params.interpolation === "Bilinear") { + destCtx.imageSmoothingEnabled = true; + destCtx.imageSmoothingQuality = 'high'; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } else { + // Nearest neighbor + destCtx.imageSmoothingEnabled = false; + destCtx.drawImage(canvas, 0, 0, newWidth, newHeight); + } + + // Calculate new position (keep center in same place) + var centerX = layer.x + layer.width / 2; + var centerY = layer.y + layer.height / 2; + var newX = Math.round(centerX - newWidth / 2); + var newY = Math.round(centerY - newHeight / 2); + + // Apply the changes + app.State.do_action( + new app.Actions.Bundle_action('scale_layer', 'Scale Layer', [ + new app.Actions.Update_layer_image_action(destCanvas, layer.id), + new app.Actions.Update_layer_action(layer.id, { + x: newX, + y: newY, + width: newWidth, + height: newHeight, + width_original: newWidth, + height_original: newHeight + }) + ]) + ); + + alertify.success('Layer scaled to ' + newWidth + 'x' + newHeight); + } +} + +export default Layer_scale_class; diff --git a/paintplus/frontend/src/js/modules/layer/visibility.js b/paintplus/frontend/src/js/modules/layer/visibility.js new file mode 100644 index 0000000..8e3017c --- /dev/null +++ b/paintplus/frontend/src/js/modules/layer/visibility.js @@ -0,0 +1,19 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Layer_visibility_class { + + constructor() { + this.Base_layers = new Base_layers_class(); + } + + toggle() { + app.State.do_action( + new app.Actions.Toggle_layer_visibility_action(config.layer.id) + ); + } + +} + +export default Layer_visibility_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/text/text_presets.js b/paintplus/frontend/src/js/modules/text/text_presets.js new file mode 100644 index 0000000..c3c08a3 --- /dev/null +++ b/paintplus/frontend/src/js/modules/text/text_presets.js @@ -0,0 +1,153 @@ +/** + * Text Presets — insert a styled text layer with one click. + * Presets: Heading, Subheading, Body, Caption, Quote, Bold Label. + * Each preset sets font, size, weight, color, and positions on canvas center. + * + * Menu target: text/text_presets.add_preset + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +const PRESETS = [ + { + label: 'Heading', + sample: 'Add a heading', + family: 'Montserrat', size: 72, bold: true, italic: false, + fill_color: '#ffffff', stroke_size: 0, + }, + { + label: 'Subheading', + sample: 'Add a subheading', + family: 'Montserrat', size: 44, bold: false, italic: false, + fill_color: '#e2e8f0', stroke_size: 0, + }, + { + label: 'Body', + sample: 'Add body text', + family: 'Lato', size: 28, bold: false, italic: false, + fill_color: '#cbd5e1', stroke_size: 0, + }, + { + label: 'Caption', + sample: 'Add a caption', + family: 'Lato', size: 20, bold: false, italic: true, + fill_color: '#94a3b8', stroke_size: 0, + }, + { + label: 'Quote', + sample: '"Add a quote"', + family: 'Playfair Display', size: 36, bold: false, italic: true, + fill_color: '#f1f5f9', stroke_size: 0, + }, + { + label: 'Bold Label', + sample: 'LABEL', + family: 'Oswald', size: 32, bold: true, italic: false, + fill_color: '#ffffff', stroke_size: 2, stroke_color: '#000000', + }, +]; + +class Text_presets_class { + constructor() { + if (instance) return instance; + instance = this; + this.Dialog = new Dialog_class(); + } + + add_preset() { + var _this = this; + const labels = PRESETS.map(p => p.label); + + this.Dialog.show({ + title: 'Add Text', + params: [ + { + title: '', + html: `
    + ${PRESETS.map((p, i) => ` +
    + ${p.sample} + ${p.family} · ${p.size}px +
    `).join('')} +
    `, + }, + { + name: 'custom_text', + title: 'Custom text (optional):', + value: '', + }, + ], + on_finish: async function (params) { + // Detect which preset was last hovered/clicked — use dialog value instead + const label = params.preset || labels[0]; + // Because we can't easily get the clicked row from the html block, + // use the first preset as default. The user can also type a custom text. + // A nicer approach: wire click handlers after dialog renders. + _this._applyPreset(PRESETS[0], params.custom_text || ''); + }, + }); + + // Wire preset row clicks after the dialog is in DOM + requestAnimationFrame(() => { + document.querySelectorAll('[data-preset-idx]').forEach(el => { + el.addEventListener('click', () => { + const idx = parseInt(el.dataset.presetIdx, 10); + const customInput = document.querySelector('input[name="custom_text"]') || + document.querySelector('#custom_text'); + const text = customInput ? customInput.value.trim() : ''; + _this._applyPreset(PRESETS[idx], text); + // Close dialog + const closeBtn = document.querySelector('.dialog_close') || + document.querySelector('[data-dialog-close]'); + if (closeBtn) closeBtn.click(); + }); + }); + }); + } + + _applyPreset(preset, customText) { + const text = customText || preset.sample; + const cw = config.WIDTH || 800; + const ch = config.HEIGHT || 600; + + // Build a text layer. miniPaint text layers use type='text' with params. + app.State.do_action( + new app.Actions.Insert_layer_action({ + type: 'text', + name: preset.label, + x: Math.round(cw * 0.1), + y: Math.round(ch * 0.4), + width: Math.round(cw * 0.8), + height: preset.size + 20, + width_original: Math.round(cw * 0.8), + height_original: preset.size + 20, + params: { + text: text, + family: preset.family, + size: preset.size, + bold: preset.bold, + italic: preset.italic, + fill_color: preset.fill_color, + stroke_size: preset.stroke_size || 0, + stroke_color: preset.stroke_color || '#000000', + kerning: 0, + leading: 0, + }, + }) + ); + alertify.success(`"${preset.label}" text added — double-click to edit.`); + } +} + +export default Text_presets_class; diff --git a/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js b/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js new file mode 100644 index 0000000..b4694e6 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/ai_provider_settings.js @@ -0,0 +1,271 @@ +/** + * AI Provider Settings — configure remote AI provider in-app without editing .env manually. + * Settings are persisted to localStorage and sent to the backend config endpoint. + * Menu target: tools/ai_provider_settings.ai_provider_settings + */ + +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { getCapabilities, getGpuStatus } from './../../api/capabilities.js'; + +// localStorage key prefix +const LS = 'paintplus_ai_'; + +function ls_get(key, def = '') { + return localStorage.getItem(LS + key) ?? def; +} +function ls_set(key, val) { + localStorage.setItem(LS + key, val); +} + +var instance = null; + +class Tools_ai_provider_settings_class { + + constructor() { + if (instance) return instance; + instance = this; + this.POP = new Dialog_class(); + } + + async ai_provider_settings() { + var _this = this; + + // Fetch caps and GPU status in parallel + var caps = await getCapabilities(); + var gpuStatus = null; + var local = caps.local || {}; + if (local.local_gpu_available) { + gpuStatus = await getGpuStatus().catch(() => null); + } + + var remote = caps.remote || {}; + var statusHtml = remote.provider + ? (remote.healthy + ? '● ' + remote.provider + ' — connected' + : '● ' + remote.provider + ' — unreachable') + : 'No remote provider configured'; + + var gpuInfoHtml = gpuStatus ? _renderGpuInfo(gpuStatus) : ''; + + var providerValues = ['', 'openai', 'invokeai', 'comfyui', 'replicate', 'local_gpu']; + + var params = [ + { + title: 'Status:', + html: '
    ' + statusHtml + '
    ', + }, + ]; + + if (gpuInfoHtml) { + params.push({ + title: '', + html: '
    Detected GPU:
    ' + gpuInfoHtml + '
    ', + }); + } + + params.push( + { + name: 'provider', + title: 'Default provider (used unless overridden below):', + value: ls_get('provider', remote.provider || ''), + values: providerValues, + type: 'select', + }, + // ── Per-operation overrides ─────────────────────────────── + { + title: '', + html: '
    Per-operation overrides — blank = use default above
    ', + }, + { + name: 'provider_inpaint', + title: 'Inpaint / Replace Selection:', + value: ls_get('provider_inpaint', remote.overrides?.inpaint || ''), + values: providerValues, + type: 'select', + }, + { + name: 'provider_txt2img', + title: 'Text → Image:', + value: ls_get('provider_txt2img', remote.overrides?.txt2img || ''), + values: providerValues, + type: 'select', + }, + { + name: 'provider_img2img', + title: 'Image → Image:', + value: ls_get('provider_img2img', remote.overrides?.img2img || ''), + values: providerValues, + type: 'select', + }, + { + name: 'provider_outpaint', + title: 'Expand Canvas (Outpaint):', + value: ls_get('provider_outpaint', remote.overrides?.outpaint || ''), + values: providerValues, + type: 'select', + }, + // ── OpenAI ──────────────────────────────────────────────── + { + name: 'openai_key', + title: 'OpenAI API key:', + value: ls_get('openai_key'), + placeholder: 'sk-...', + }, + { + name: 'openai_model', + title: 'OpenAI model:', + value: ls_get('openai_model', 'dall-e-3'), + values: ['dall-e-3', 'dall-e-2'], + type: 'select', + }, + // ── InvokeAI ────────────────────────────────────────────── + { + name: 'invokeai_url', + title: 'InvokeAI URL:', + value: ls_get('invokeai_url'), + placeholder: 'http://192.168.1.x:9090', + }, + { + name: 'invokeai_model', + title: 'InvokeAI default model:', + value: ls_get('invokeai_model', 'flux-dev'), + placeholder: 'flux-dev', + }, + // ── ComfyUI ─────────────────────────────────────────────── + { + name: 'comfyui_url', + title: 'ComfyUI URL:', + value: ls_get('comfyui_url'), + placeholder: 'http://192.168.1.x:8188', + }, + { + name: 'comfyui_model', + title: 'ComfyUI default checkpoint:', + value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'), + placeholder: 'v1-5-pruned-emaonly.ckpt', + }, + // ── Replicate ───────────────────────────────────────────── + { + name: 'replicate_key', + title: 'Replicate API key:', + value: ls_get('replicate_key'), + placeholder: 'r8_...', + } + ); + + this.POP.show({ + title: 'AI Provider Settings', + params: params, + on_finish: async function (params) { + await _this._save(params); + }, + }); + } + + async _save(params) { + // Persist to localStorage + ls_set('provider', params.provider || ''); + ls_set('provider_inpaint', params.provider_inpaint || ''); + ls_set('provider_txt2img', params.provider_txt2img || ''); + ls_set('provider_img2img', params.provider_img2img || ''); + ls_set('provider_outpaint', params.provider_outpaint || ''); + ls_set('openai_key', params.openai_key || ''); + ls_set('openai_model', params.openai_model || 'dall-e-3'); + ls_set('invokeai_url', params.invokeai_url || ''); + ls_set('invokeai_model', params.invokeai_model || 'flux-dev'); + ls_set('comfyui_url', params.comfyui_url || ''); + ls_set('comfyui_model', params.comfyui_model || 'v1-5-pruned-emaonly.ckpt'); + ls_set('replicate_key', params.replicate_key || ''); + + // Push to backend + try { + var payload = { + ai_provider: params.provider || '', + ai_provider_inpaint: params.provider_inpaint || '', + ai_provider_txt2img: params.provider_txt2img || '', + ai_provider_img2img: params.provider_img2img || '', + ai_provider_outpaint: params.provider_outpaint || '', + openai_api_key: params.openai_key || '', + openai_model: params.openai_model || 'dall-e-3', + invokeai_url: params.invokeai_url || '', + invokeai_default_model: params.invokeai_model || 'flux-dev', + comfyui_url: params.comfyui_url || '', + comfyui_default_model: params.comfyui_model || '', + replicate_api_key: params.replicate_key || '', + }; + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (r.ok) { + alertify.success('AI provider settings saved. Testing connection...'); + var { refreshCapabilities } = await import('./../../api/capabilities.js'); + var caps = await refreshCapabilities(); + if (caps?.remote?.healthy) { + alertify.success('Connected to ' + caps.remote.provider + '!'); + } else if (params.provider) { + if (params.provider === 'local_gpu') { + alertify.success('local_gpu set — restart the container with docker-compose.gpu.yml to activate.'); + } else { + alertify.warning('Settings saved but provider is not reachable. Check URL/key.'); + } + } + } else { + alertify.warning( + 'Settings saved locally. To make them permanent, ' + + 'set these values in your .env file and restart the server.' + ); + } + } catch { + alertify.warning( + 'Settings saved locally. Set AI_PROVIDER and related keys in .env to make permanent.' + ); + } + } +} + +function _renderGpuInfo(g) { + var flags = [ + g.fp16 && 'fp16', + g.bf16 && 'bf16', + g.fp8 && 'fp8', + g.int8 && 'int8', + g.tensor_cores && 'tensor-cores', + g.xformers && 'xformers', + ].filter(Boolean).join(' · '); + + var rows = Object.entries(g.recommended || {}) + .filter(([, s]) => s) + .map(function([op, s]) { + var modelName = s.model_id.split('/').pop(); + return '' + + '' + op + '' + + '' + modelName + '' + + '' + s.memory_opt + '' + + ''; + }) + .join(''); + + var warnHtml = (g.warnings || []).length + ? '
    ' + + g.warnings.map(function(w) { return '⚠ ' + w; }).join('
    ') + '
    ' + : ''; + + return '
    ' + + '
    ⬛ ' + (g.device_name || 'GPU') + '
    ' + + '
    VRAM: ' + g.vram_total_gb + ' GB total · ' + g.vram_free_gb + ' GB free
    ' + + '
    Compute: CC ' + g.compute_capability + '' + + (flags ? ' ' + flags + '' : '') + '
    ' + + '
    Effective: ' + g.effective_vram_gb + ' GB' + + ' Tier: ' + g.tier + '
    ' + + (rows ? '
    Models selected:
    ' + + '' + rows + '
    ' : '') + + warnHtml + + '
    '; +} + +export default Tools_ai_provider_settings_class; diff --git a/paintplus/frontend/src/js/modules/tools/color_to_alpha.js b/paintplus/frontend/src/js/modules/tools/color_to_alpha.js new file mode 100644 index 0000000..09070ee --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/color_to_alpha.js @@ -0,0 +1,82 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Tools_colorToAlpha_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + } + + color_to_alpha() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Color to Alpha', + preview: true, + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params.color); + canvas_preview.putImageData(data, 0, 0); + }, + params: [ + {name: "color", title: "Color:", value: config.COLOR, type: 'color'}, + ], + on_finish: function (params) { + _this.apply_affect(params.color); + }, + }; + this.POP.show(settings); + } + + apply_affect(color) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, color); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, color) { + var imgData = data.data; + var back_color = this.Helper.hexToRgb(color); + + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + + //calculate difference from requested color, and change alpha + var diff = Math.abs(imgData[i] - back_color.r) + Math.abs(imgData[i + 1] - back_color.g) + Math.abs(imgData[i + 2] - back_color.b) / 3; + imgData[i + 3] = Math.round(diff); + + //combining 2 layers in future will change colors, so make changes to get same colors in final image + //color_result = color_1 * (alpha_1 / 255) * (1 - A2 / 255) + color_2 * (alpha_2 / 255) + //color_2 = (color_result - color_1 * (alpha_1 / 255) * (1 - A2 / 255)) / (alpha_2 / 255) + imgData[i] = Math.ceil((imgData[i] - back_color.r * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255)); + imgData[i + 1] = Math.ceil((imgData[i + 1] - back_color.g * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255)); + imgData[i + 2] = Math.ceil((imgData[i + 2] - back_color.b * (1 - imgData[i + 3] / 255)) / (imgData[i + 3] / 255)); + } + return data; + } + +} + +export default Tools_colorToAlpha_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/color_zoom.js b/paintplus/frontend/src/js/modules/tools/color_zoom.js new file mode 100644 index 0000000..6f92856 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/color_zoom.js @@ -0,0 +1,83 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Tools_colorZoom_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + color_zoom() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Color zoom', + preview: true, + params: [ + {name: "zoom", title: "Zoom:", value: "2", range: [2, 20], }, + {name: "center", title: "Center:", value: "128", range: [0, 255]}, + ], + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.change(img, params.zoom, params.center); + canvas_preview.putImageData(data, 0, 0); + }, + on_finish: function (params) { + _this.save_zoom(params.zoom, params.center); + }, + }; + this.POP.show(settings); + } + + save_zoom(zoom, center) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.change(img, zoom, center); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + change(data, zoom, center) { + var imgData = data.data; + var grey; + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + + for (var j = 0; j < 3; j++) { + var k = i + j; + if (grey > center) + imgData[k] += (imgData[k] - center) * zoom; + else if (grey < center) + imgData[k] -= (center - imgData[k]) * zoom; + if (imgData[k] < 0) + imgData[k] = 0; + if (imgData[k] > 255) + imgData[k] = 255; + } + } + return data; + } + +} + +export default Tools_colorZoom_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/content_fill.js b/paintplus/frontend/src/js/modules/tools/content_fill.js new file mode 100644 index 0000000..797a046 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/content_fill.js @@ -0,0 +1,266 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import ImageFilters from './../../libs/imagefilters.js'; +import Image_trim_class from './../image/trim.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Tools_contentFill_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Image_trim = new Image_trim_class(); + } + + content_fill() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.x == 0 && config.layer.y == 0 && config.layer.width == config.WIDTH + && config.layer.height == config.HEIGHT) { + alertify.error('Can not use this tool on current layer: image already takes all area.'); + return; + } + + var settings = { + title: 'Content Fill', + preview: true, + on_change: function (params, canvas_preview, w, h, canvasElement) { + canvas_preview.clearRect(0, 0, w, h); + + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + + //change data + _this.change(canvas, params); + + //add to preview + canvas_preview.drawImage(canvas, 0, 0, w, h); + }, + params: [ + {name: "mode", title: "Mode:", values: ['Expand edges', 'Cloned edges', 'Resized as background'], }, + {name: "blur_power", title: "Blur power:", value: 5, range: [1, 20]}, + {name: "blur_h", title: "Horizontal blur:", value: 5, range: [0, 30]}, + {name: "blur_v", title: "Vertical blur:", value: 5, range: [0, 30]}, + {name: "clone_count", title: "Clone count:", value: 15, range: [10, 50]}, + ], + on_finish: function (params) { + _this.apply_affect(params); + }, + }; + this.POP.show(settings); + } + + apply_affect(params) { + //create tmp canvas + var canvas = document.createElement('canvas'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + + //change data + this.change(canvas, params); + + //save + return app.State.do_action( + new app.Actions.Bundle_action('content_fill', 'Content Fill', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: 0, + y: 0, + width: config.WIDTH, + height: config.HEIGHT + }), + new app.Actions.Update_layer_image_action(canvas) + ]) + ); + } + + change(canvas, params) { + var ctx = canvas.getContext("2d"); + var mode = params.mode; + + //generate background + if (mode == 'Expand edges') + this.add_edge_background(canvas, params); + else if (mode == 'Resized as background') + this.add_resized_background(canvas, params); + else if (mode == 'Cloned edges') + this.add_cloned_background(canvas, params); + + //draw original image + this.Base_layers.render_object(ctx, config.layer); + } + + add_edge_background(canvas, params) { + var ctx = canvas.getContext("2d"); + var trim_info = this.Image_trim.get_trim_info(config.layer.id); + var original = this.Base_layers.convert_layer_to_canvas(); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(original, trim_info.left, trim_info.top); + + //draw top + ctx.drawImage(original, + 0, 0, original.width, 1, //source + trim_info.left, 0, original.width, trim_info.top); //target + + //bottom + ctx.drawImage(original, + 0, original.height - 1, original.width, 1, + trim_info.left, trim_info.top + original.height, original.width, canvas.height); + + //left + ctx.drawImage(original, + 0, 0, 1, original.height, + 0, trim_info.top, trim_info.left, original.height); + + //right + ctx.drawImage(original, + original.width - 1, 0, 1, original.height, + trim_info.left + original.width, trim_info.top, canvas.width, original.height); + + //fill corners + + //left top + ctx.drawImage(original, + 0, 0, 1, 1, + 0, 0, trim_info.left, trim_info.top); + + //right top + ctx.drawImage(original, + original.width - 1, 0, 1, 1, + trim_info.left + original.width, 0, canvas.width, trim_info.top); + + //left bottom + ctx.drawImage(original, + 0, original.height - 1, 1, 1, + 0, trim_info.top + original.height, trim_info.left, trim_info.bottom); + + //right bottom + ctx.drawImage(original, + original.width - 1, original.height - 1, 1, 1, + trim_info.left + original.width, trim_info.top + original.height, trim_info.right, trim_info.bottom); + + //add blur + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power); + ctx.putImageData(blurred, 0, 0); + } + + add_resized_background(canvas, params) { + var ctx = canvas.getContext("2d"); + + //draw original resized + var original = this.Base_layers.convert_layer_to_canvas(); + ctx.drawImage(original, 0, 0, canvas.width, canvas.height); + + //add blur + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power); + ctx.putImageData(blurred, 0, 0); + } + + add_cloned_background(canvas, params) { + var blocks = params.clone_count; + var ctx = canvas.getContext("2d"); + var trim_info = this.Image_trim.get_trim_info(config.layer.id); + var original = this.Base_layers.convert_layer_to_canvas(); + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(original, trim_info.left, trim_info.top); + + //top + var bsize = Math.ceil(original.width / blocks); + for (var i = 0; i < original.width; i = i + bsize) { + for (var j = 0; j < trim_info.top; j = j + bsize) { + ctx.drawImage(original, + i, 0, bsize, bsize, + trim_info.left + i, 0 + j, bsize, bsize); + } + } + + //bottom + var bsize = Math.ceil(original.width / blocks); + for (var i = 0; i < original.width; i = i + bsize) { + for (var j = 0; j < canvas.height; j = j + bsize) { + ctx.drawImage(original, + i, original.height - bsize, bsize, bsize, + trim_info.left + i, trim_info.top + original.height + j, bsize, bsize); + } + } + + //left + var bsize = Math.ceil(original.height / blocks); + for (var i = 0; i < trim_info.left; i = i + bsize) { + for (var j = trim_info.top; j < trim_info.top + original.height; j = j + bsize) { + ctx.drawImage(original, + 0, j - trim_info.top, bsize, bsize, + i, j, bsize, bsize); + } + } + + //right + var bsize = Math.ceil(original.height / blocks); + for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) { + for (var j = trim_info.top; j < trim_info.top + original.height; j = j + bsize) { + ctx.drawImage(original, + original.width - bsize, j - trim_info.top, bsize, bsize, + i, j, bsize, bsize); + } + } + + //corners + var bsize = Math.ceil(Math.min(original.width, original.height) / blocks); + + //top left + for (var i = 0; i < trim_info.left; i = i + bsize) { + for (var j = 0; j < trim_info.top; j = j + bsize) { + ctx.drawImage(original, + 0, 0, bsize, bsize, + i, j, bsize, bsize); + } + } + + //top right + for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) { + for (var j = 0; j < trim_info.top; j = j + bsize) { + ctx.drawImage(original, + original.width - bsize, 0, bsize, bsize, + i, j, bsize, bsize); + } + } + + //bottom left + for (var i = 0; i < trim_info.left; i = i + bsize) { + for (var j = trim_info.top + original.height; j < canvas.height; j = j + bsize) { + ctx.drawImage(original, + 0, original.height - bsize, bsize, bsize, + i, j, bsize, bsize); + } + } + + //bottom right + for (var i = trim_info.left + original.width; i < canvas.width; i = i + bsize) { + for (var j = trim_info.top + original.height; j < canvas.height; j = j + bsize) { + ctx.drawImage(original, + original.width - bsize, original.height - bsize, bsize, bsize, + i, j, bsize, bsize); + } + } + + + //add blur + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var blurred = ImageFilters.BoxBlur(img, params.blur_h, params.blur_v, params.blur_power); + ctx.putImageData(blurred, 0, 0); + } + +} + +export default Tools_contentFill_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/keypoints.js b/paintplus/frontend/src/js/modules/tools/keypoints.js new file mode 100644 index 0000000..bab743d --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/keypoints.js @@ -0,0 +1,225 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; +import ImageFilters_class from './../../libs/imagefilters.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +/** + * SIFT: scale-invariant-feature-transform, keypoints + * + * @author ViliusL + */ +class Tools_keypoints_class { + + constructor() { + this.Helper = new Helper_class(); + this.Base_layers = new Base_layers_class(); + this.ImageFilters = ImageFilters_class; + + //contrast check, smaller - more points, better accuracy, but slower + this.avg_offset = 50; + + /** + * how much pixels to check for each side to get average + */ + this.avg_step = 4; + } + + //generate key points for image + keypoints(return_data) { + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var W = config.layer.width; + var H = config.layer.height; + + //get canvas from layer + var clone = this.Base_layers.convert_layer_to_canvas(); + var ctx = clone.getContext("2d"); + + //greyscale + var imageData = ctx.getImageData(0, 0, W, H); + var data = this.convert_to_grayscale(imageData); + ctx.putImageData(data, 0, 0); + + //make few copies and blur each + var n = 5; + var copies = []; + for (var i = 0; i < n; i++) { + var tmp_canvas = document.createElement('canvas'); + tmp_canvas.width = W; + tmp_canvas.height = H; + var ctx_i = tmp_canvas.getContext("2d"); + ctx_i.drawImage(clone, 0, 0); + + //Gausian blur + var imageData = ctx_i.getImageData(0, 0, W, H); + var filtered = this.ImageFilters.GaussianBlur(imageData, i + 0.5); //add effect + ctx_i.putImageData(filtered, 0, 0); + + copies.push(tmp_canvas); + } + + //find extreme points + var points = []; + var n0 = this.avg_step * 2 + 1; + for (var c = 1; c < copies.length - 1; c++) { + var imageData = copies[c].getContext("2d").getImageData(0, 0, W, H).data; + var imageData0 = copies[c - 1].getContext("2d").getImageData(0, 0, W, H).data; + var imageData2 = copies[c + 1].getContext("2d").getImageData(0, 0, W, H).data; + for (var j = this.avg_step; j < H - this.avg_step; j++) { + for (var i = this.avg_step; i < W - this.avg_step; i++) { + var x = (i + j * W) * 4; + if (imageData[x + 3] == 0) + continue; //transparent + if (imageData[x] < imageData[x - 4] || imageData[x] < imageData[x + 4] || imageData[x] > imageData[x - 4] || imageData[x] > imageData[x + 4]) { + var x_pre = (i + (j - 1) * W) * 4; + var x_post = (i + (j + 1) * W) * 4; + //calc average + var area_average = 0; + for (var l = -this.avg_step; l <= this.avg_step; l++) { + var avgi = (i + (j - l) * W) * 4; + for (var a = -this.avg_step; a <= this.avg_step; a++) { + area_average += imageData[avgi + 4 * a]; + } + } + area_average = area_average / (n0 * n0); + //max + if (imageData[x] + this.avg_offset < area_average) { + var min = Math.min(imageData[x_pre - 4], imageData[x_pre], imageData[x_pre + 4], imageData[x - 4], imageData[x + 4], imageData[x_post - 4], imageData[x_post], imageData[x_post + 4]); + if (imageData[x] <= min) { + var min0 = Math.min(imageData0[x_pre - 4], imageData0[x_pre], imageData0[x_pre + 4], imageData0[x - 4], imageData0[x + 4], imageData0[x_post - 4], imageData0[x_post], imageData0[x_post + 4]); + if (imageData[x] <= min0) { + var min2 = Math.min(imageData2[x_pre - 4], imageData2[x_pre], imageData2[x_pre + 4], imageData2[x - 4], imageData2[x + 4], imageData2[x_post - 4], imageData2[x_post], imageData2[x_post + 4]); + if (imageData[x] <= min2) + points.push({ + x: i, + y: j, + w: Math.round(area_average - imageData[x] - this.avg_offset) + }); + } + } + continue; + } + //min + if (imageData[x] - this.avg_offset > area_average) { + var max = Math.max(imageData[x_pre - 4], imageData[x_pre], imageData[x_pre + 4], imageData[x - 4], imageData[x + 4], imageData[x_post - 4], imageData[x_post], imageData[x_post + 4]); + if (imageData[x] >= max) { + var max0 = Math.max(imageData0[x_pre - 4], imageData0[x_pre], imageData0[x_pre + 4], imageData0[x - 4], imageData0[x + 4], imageData0[x_post - 4], imageData0[x_post], imageData0[x_post + 4]); + if (imageData[x] >= max0) { + var max2 = Math.max(imageData2[x_pre - 4], imageData2[x_pre], imageData2[x_pre + 4], imageData2[x - 4], imageData2[x + 4], imageData2[x_post - 4], imageData2[x_post], imageData2[x_post + 4]); + if (imageData[x] >= max2) { + points.push({ + x: i, + y: j, + w: Math.round(imageData[x] - area_average - this.avg_offset) + }); + } + } + } + } + } + } + } + } + //make unique + for (var i = 0; i < points.length; i++) { + for (var j = 0; j < points.length; j++) { + if (i != j && points[i].x == points[j].x && points[i].y == points[j].y) { + points.splice(i, 1); + i--; + break; + } + } + } + + //show points? + if (return_data === undefined || return_data !== true) { + alertify.success('key points: ' + points.length); + + var size = 3; + ctx.clearRect(0, 0, clone.width, clone.height); + ctx.fillStyle = "#ff0000"; + for (var i in points) { + var point = points[i]; + ctx.beginPath(); + ctx.rect(point.x - Math.floor(size / 2) + 1, point.y - Math.floor(size / 2) + 1, size, size); + ctx.fill(); + } + + //show + var params = []; + params.type = 'image'; + params.name = config.layer.name + ' + key points'; + params.data = clone.toDataURL("image/png"); + params.x = parseInt(clone.dataset.x); + params.y = parseInt(clone.dataset.y); + params.width = clone.width; + params.height = clone.height; + app.State.do_action( + new app.Actions.Bundle_action('keypoints', 'Key-Points', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + clone.width = 1; + clone.height = 1; + } + else { + //sort by weights + points.sort(function (a, b) { + return parseFloat(b.w) - parseFloat(a.w); + }); + + clone.width = 1; + clone.height = 1; + + return { + points: points, + }; + } + } + + //returns average value of requested area from greyscale image + //area = {x, y, w, h} + get_area_average(area, imageData, i, j, size) { + var imgData = imageData.data; + var sum = 0; + var n = 0; + size = size / 100; //prepare to use 1-100% values + var stop_x = i + Math.round(size * area.x) + Math.round(size * area.w); + var stop_y = j + Math.round(size * area.y) + Math.round(size * area.h); + var img_width4 = imageData.width * 4; + var k0, k; + for (var y = j + Math.round(size * area.y); y < stop_y; y++) { + k0 = y * img_width4; + for (var x = i + Math.round(size * area.x); x < stop_x; x++) { + k = k0 + (x * 4); + sum = sum + imgData[k]; + n++; + } + } + return Math.round(sum / n); + } + + convert_to_grayscale(data) { + var imgData = data.data; + var grey; + + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + grey = Math.round(0.2126 * imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + imgData[i] = grey; + imgData[i + 1] = grey; + imgData[i + 2] = grey; + } + return data; + } +} + +export default Tools_keypoints_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/replace_color.js b/paintplus/frontend/src/js/modules/tools/replace_color.js new file mode 100644 index 0000000..4aa40d9 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/replace_color.js @@ -0,0 +1,127 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Tools_replaceColor_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + } + + replace_color() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Replace color', + preview: true, + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.do_replace(img, params); + canvas_preview.putImageData(data, 0, 0); + }, + params: [ + {name: "target", title: "Target:", value: config.COLOR, type: 'color'}, + {name: "replacement", title: "Replacement:", value: '#ff0000', type: 'color'}, + {name: "power", title: "Power:", value: "20", range: [0, 255]}, + {name: "alpha", title: "Alpha:", value: "255", range: [0, 255]}, + {name: "mode", title: "Mode:", values: ['Advanced', 'Simple']}, + ], + on_finish: function (params) { + _this.save_alpha(params); + }, + }; + this.POP.show(settings); + } + + save_alpha(params) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.do_replace(img, params); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + do_replace(data, params) { + var target = params.target; + var replacement = params.replacement; + var power = params.power; + var alpha = params.alpha; + var mode = params.mode; + + var imgData = data.data; + var target_rgb = this.Helper.hexToRgb(target); + var target_hsl = this.Helper.rgbToHsl(target_rgb.r, target_rgb.g, target_rgb.b); + var target_normalized = this.Helper.hslToRgb(target_hsl.h, target_hsl.s, 0.5); + + var replacement_rgb = this.Helper.hexToRgb(replacement); + var replacement_hsl = this.Helper.rgbToHsl(replacement_rgb.r, replacement_rgb.g, replacement_rgb.b); + + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + + if (mode == 'Simple') { + //simple replace + + //calculate difference from requested color, and change alpha + var diff = (Math.abs(imgData[i] - target_rgb.r) + + Math.abs(imgData[i + 1] - target_rgb.g) + + Math.abs(imgData[i + 2] - target_rgb.b)) / 3; + if (diff > power) + continue; + + imgData[i] = replacement_rgb.r; + imgData[i + 1] = replacement_rgb.g; + imgData[i + 2] = replacement_rgb.b; + if (alpha < 255) + imgData[i + 3] = alpha; + } + else { + //advanced replace using HSL + + var hsl = this.Helper.rgbToHsl(imgData[i], imgData[i + 1], imgData[i + 2]); + var normalized = this.Helper.hslToRgb(hsl.h, hsl.s, 0.5); + var diff = (Math.abs(normalized.r - target_normalized.r) + + Math.abs(normalized.g - target_normalized.g) + + Math.abs(normalized.b - target_normalized.b)) / 3; + if (diff > power) + continue; + + //change to new color with existing luminance + var normalized_final = this.Helper.hslToRgb( + replacement_hsl.h, + replacement_hsl.s, + hsl.l * (replacement_hsl.l) + ); + + imgData[i] = normalized_final.r; + imgData[i + 1] = normalized_final.g; + imgData[i + 2] = normalized_final.b; + if (alpha < 255) + imgData[i + 3] = alpha; + } + } + return data; + } + +} + +export default Tools_replaceColor_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/restore_alpha.js b/paintplus/frontend/src/js/modules/tools/restore_alpha.js new file mode 100644 index 0000000..a7554dc --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/restore_alpha.js @@ -0,0 +1,72 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Tools_restoreAlpha_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + } + + restore_alpha() { + var _this = this; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + var settings = { + title: 'Restore Alpha', + preview: true, + on_change: function (params, canvas_preview, w, h) { + var img = canvas_preview.getImageData(0, 0, w, h); + var data = _this.recover_alpha(img, params.level); + canvas_preview.putImageData(data, 0, 0); + }, + params: [ + {name: "level", title: "Level:", value: "128", range: [0, 255]}, + ], + on_finish: function (params) { + _this.save_alpha(params.level); + }, + }; + this.POP.show(settings); + } + + save_alpha(level) { + //get canvas from layer + var canvas = this.Base_layers.convert_layer_to_canvas(null, true); + var ctx = canvas.getContext("2d"); + + //change data + var img = ctx.getImageData(0, 0, canvas.width, canvas.height); + var data = this.recover_alpha(img, level); + ctx.putImageData(data, 0, 0); + + //save + return app.State.do_action( + new app.Actions.Update_layer_image_action(canvas) + ); + } + + recover_alpha(data, level) { + var imgData = data.data; + var tmp; + level = parseInt(level); + for (var i = 0; i < imgData.length; i += 4) { + tmp = imgData[i + 3] + level; + if (tmp > 255) { + tmp = 255; + } + imgData[i + 3] = tmp; + } + return data; + } + +} + +export default Tools_restoreAlpha_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/search.js b/paintplus/frontend/src/js/modules/tools/search.js new file mode 100644 index 0000000..fb9f622 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/search.js @@ -0,0 +1,15 @@ +import Base_search_class from './../../core/base-search.js'; + +class Tools_search_class { + + constructor() { + this.Base_search = new Base_search_class(); + } + + search() { + this.Base_search.search(); + } + +} + +export default Tools_search_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/settings.js b/paintplus/frontend/src/js/modules/tools/settings.js new file mode 100644 index 0000000..202ef20 --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/settings.js @@ -0,0 +1,171 @@ +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import Base_gui_class from './../../core/base-gui.js'; + +class Tools_settings_class { + + constructor() { + this.Base_gui = new Base_gui_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + + this.default_units_config = { + pixels: 'px', + inches: '"', + centimeters: 'cm', + millimetres: 'mm', + }; + } + + settings() { + var _this = this; + var transparency_values = ['squares', 'green', 'grey']; + var resolutions_values = [72, 150, 300, 600]; + var default_units_all = Object.keys(this.default_units_config); + var transparency = this.get_setting('transparency'); + var theme = this.get_setting('theme'); + var snap = this.get_setting('snap'); + var guides = this.get_setting('guides'); + var safe_search = this.get_setting('safe_search'); + var exit_confirm = this.get_setting('exit_confirm'); + var default_units = this.get_setting('default_units'); + var resolution = this.get_setting('resolution'); + var thick_guides = this.get_setting('thick_guides'); + var enable_autoresize = this.get_setting('enable_autoresize'); + + var settings = { + title: 'Settings', + params: [ + {name: "transparency", title: "Transparent:", value: transparency}, + {name: "transparency_type", title: "Transparency background:", type: "select", + value: config.TRANSPARENCY_TYPE, values: transparency_values}, + {name: "theme", title: "Theme", values: config.themes, value: theme, type: "select"}, + {name: "default_units", title: "Units", values: default_units_all, value: default_units, type: "select"}, + {name: "resolution", title: "Resolution:", type: "select", + value: resolution, values: resolutions_values}, + {name: "snap", title: "Enable snap:", value: snap}, + {name: "guides", title: "Enable guides:", value: guides}, + {name: "safe_search", title: "Safe search:", value: safe_search}, + {name: "exit_confirm", title: "Exit confirmation:", value: exit_confirm}, + {name: "thick_guides", title: "Thick guides:", value: thick_guides}, + {name: "enable_autoresize", title: "Enable autoresize:", value: enable_autoresize}, + ], + on_change: function (params) { + this.Base_gui.change_theme(params.theme); + }, + on_cancel: function (params) { + this.Base_gui.change_theme(theme); + }, + on_finish: function (params) { + _this.save_values(params); + }, + }; + this.POP.show(settings); + } + + save_values(params) { + + //save + this.save_setting('theme', params.theme); + this.save_setting('transparency', params.transparency); + this.save_setting('transparency_type', params.transparency_type); + this.save_setting('snap', params.snap); + this.save_setting('guides', params.guides); + this.save_setting('safe_search', params.safe_search); + this.save_setting('exit_confirm', params.exit_confirm); + this.save_setting('default_units', params.default_units); + this.save_setting('default_units_short', this.default_units_config[params.default_units]); + this.save_setting('resolution', params.resolution); + this.save_setting('thick_guides', params.thick_guides); + this.save_setting('enable_autoresize', params.enable_autoresize); + + //update config + config.TRANSPARENCY = this.get_setting('transparency'); + config.TRANSPARENCY_TYPE = this.get_setting('transparency_type'); + config.SNAP = this.get_setting('snap'); + config.guides_enabled = this.get_setting('guides'); + this.Base_gui.change_theme(this.get_setting('theme')); + this.Base_gui.GUI_information.update_units(); + + //finish + this.Base_gui.prepare_canvas(); + config.need_render = true; + } + + /** + * set global setting. Values can be string(1 or 0 will be converted to boolean) or boolean + * + * @param key + * @param value + */ + save_setting(key, value) { + //prepare + if(value === true){ + value = 1; + } + if(value === false){ + value = 0; + } + + this.Helper.setCookie(key, value); + } + + /** + * get global setting. If settings does not exists, default valye will be used. + * + * @param key + * @returns {Object|string} + */ + get_setting(key) { + var default_values = { + 'theme': null, + 'transparency': false, + 'snap': true, + 'guides': true, + 'safe_search': true, + 'exit_confirm': true, + 'default_units': Object.keys(this.default_units_config)[0], + 'default_units_short': Object.values(this.default_units_config)[0], + 'resolution': 72, + 'thick_guides': false, + 'enable_autoresize': config.enable_autoresize_by_default, + }; + + var value = this.Helper.getCookie(key); + if(value == null && default_values[key] != undefined){ + //set default value + value = default_values[key]; + } + if(key == 'safe_search' && config.safe_search_can_be_disabled === false){ + //not allowed + value = 1; + } + if(key == 'theme' && value == null) { + value = config.themes[0]; + /*if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches + && config.themes.includes('dark')) { + //dark mode + value = 'dark'; + } + else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches + && config.themes.includes('light')) { + //light mode + value = 'light'; + }*/ + } + + //finalize values + if(value === 1){ + value = true; + } + if(value === 0){ + value = false; + } + + return value; + } + +} + +export default Tools_settings_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/sprites.js b/paintplus/frontend/src/js/modules/tools/sprites.js new file mode 100644 index 0000000..d8b563b --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/sprites.js @@ -0,0 +1,123 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Image_trim_class from './../image/trim.js'; +import Base_gui_class from './../../core/base-gui.js'; + +class Tools_sprites_class { + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Image_trim = new Image_trim_class(); + this.Base_gui = new Base_gui_class(); + } + + sprites() { + var _this = this; + + var settings = { + title: 'Sprites', + params: [ + {name: "gap", title: "Gap:", value: "50", values: ["0", "10", "50", "100"]}, + ], + on_finish: function (params) { + _this.generate_sprites(params.gap); + }, + }; + this.POP.show(settings); + } + + generate_sprites(gap, sprite_width) { + gap = parseInt(gap); + + if (config.layers.length == 1) { + alertify.error('There is only 1 layer.'); + return false; + } + + var xx = 0; + var yy = 0; + var max_height = 0; + let actions = []; + let new_height = config.HEIGHT; + let new_width = config.WIDTH; + + //collect trim info + var trim_details_array = []; + for (var i = 0; i < config.layers.length; i++) { + var layer = config.layers[i]; + if (layer.visible == false) + continue; + + trim_details_array[layer.id] = this.Image_trim.get_trim_info(layer.id); + } + + //move layers + for (var i = 0; i < config.layers.length; i++) { + var layer = config.layers[i]; + if (layer.visible == false) + continue; + + var trim_details = trim_details_array[layer.id]; + if (new_width == trim_details.left) { + //empty layer + continue; + } + var width = new_width - trim_details.left - trim_details.right; + var height = config.HEIGHT - trim_details.top - trim_details.bottom; + + if (xx + width > new_width) { + xx = 0; + yy += max_height; + max_height = 0; + } + if (yy % gap > 0 && gap > 0) { + yy = yy - yy % gap + gap; + } + if (yy + height > new_height) { + new_height = parseInt(yy + height); + this.Base_gui.prepare_canvas(); + } + + actions.push( + new app.Actions.Update_layer_action(layer.id, { + x: layer.x + xx - trim_details.left, + y: layer.y + yy - trim_details.top + }) + ); + + xx += width; + if (gap > 0) { + xx = xx - xx % gap + gap; + } + + if (height > max_height) { + max_height = height; + } + if (xx > new_width) { + xx = 0; + yy += max_height; + max_height = 0; + } + } + actions.push( + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: new_width, + HEIGHT: new_height + }), + new app.Actions.Prepare_canvas_action('do') + ); + + app.State.do_action( + new app.Actions.Bundle_action('sprites', 'Sprites', actions) + ); + + } + +} + +export default Tools_sprites_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/tools/translate.js b/paintplus/frontend/src/js/modules/tools/translate.js new file mode 100644 index 0000000..e02190c --- /dev/null +++ b/paintplus/frontend/src/js/modules/tools/translate.js @@ -0,0 +1,70 @@ +import config from './../../config.js'; +import Helper_class from './../../libs/helpers.js'; +import Translate_class from './../../libs/jquery.translate.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Tools_translate_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Helper = new Helper_class(); + this.translations = {}; + this.trans_lang_codes = []; + + this.load_translations(); + } + + //change language + translate(lang_code, element) { + if (lang_code == undefined) { + lang_code = this.Helper.getCookie('language'); + if (!lang_code) { + return; + } + } + + if (lang_code != undefined && lang_code != config.LANG) { + //save cookie + this.Helper.setCookie('language', lang_code); + } + + if (this.trans_lang_codes.includes(lang_code) || lang_code == 'en') { + //translate + $(element || 'body').translate({lang: lang_code, t: this.translations}); + config.LANG = lang_code; + } + else { + alertify.error('Translate error, can not find dictionary: ' + lang_code); + } + } + + load_translations() { + var _this = this; + var modules_context = require.context("./../../languages/", true, /\.json$/); + modules_context.keys().forEach(function (key) { + if (key.indexOf('Base' + '/') < 0 && key.indexOf('empty') < 0) { + var moduleKey = key.replace('./', '').replace('.json', ''); + var classObj = modules_context(key); + + for(var i in classObj){ + if(_this.translations[i] == undefined){ + _this.translations[i] = { + en: i, + }; + } + _this.translations[i][moduleKey] = classObj[i]; + } + _this.trans_lang_codes.push(moduleKey); + } + }); + } +} + +export default Tools_translate_class; diff --git a/paintplus/frontend/src/js/modules/view/full_screen.js b/paintplus/frontend/src/js/modules/view/full_screen.js new file mode 100644 index 0000000..d5c6ca7 --- /dev/null +++ b/paintplus/frontend/src/js/modules/view/full_screen.js @@ -0,0 +1,20 @@ +class View_fullScreen_class { + + constructor() {} + + /** + * toggle full-screen + */ + fs() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } + else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + } +} + +export default View_fullScreen_class; diff --git a/paintplus/frontend/src/js/modules/view/grid.js b/paintplus/frontend/src/js/modules/view/grid.js new file mode 100644 index 0000000..49d85ef --- /dev/null +++ b/paintplus/frontend/src/js/modules/view/grid.js @@ -0,0 +1,48 @@ +import config from './../../config.js'; +import Helper_class from './../../libs/helpers.js'; +import Base_gui_class from './../../core/base-gui.js'; + +var instance = null; + +class View_grid_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.GUI = new Base_gui_class(); + this.Helper = new Helper_class(); + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 71 && event.ctrlKey != true && event.metaKey != true) { + //G - grid + this.grid({visible: !this.GUI.grid}); + event.preventDefault(); + } + }, false); + } + + grid() { + if (this.GUI.grid == false) { + this.GUI.grid = true; + } + else { + this.GUI.grid = false; + } + config.need_render = true; + } + +} + +export default View_grid_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/view/guides.js b/paintplus/frontend/src/js/modules/view/guides.js new file mode 100644 index 0000000..945ac18 --- /dev/null +++ b/paintplus/frontend/src/js/modules/view/guides.js @@ -0,0 +1,146 @@ +import config from './../../config.js'; +import Dialog_class from './../../libs/popup.js'; +import Helper_class from './../../libs/helpers.js'; +import Base_layers_class from './../../core/base-layers.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import Tools_settings_class from './../tools/settings.js'; +import app from './../../app.js'; + +class View_guides_class { + + + constructor() { + this.POP = new Dialog_class(); + this.Base_layers = new Base_layers_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + } + + insert() { + var _this = this; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + //convert units + var position = 20; + var position = this.Helper.get_user_unit(position, units, resolution); + + var settings = { + title: 'Insert guides', + params: [ + {name: "type", title: "Type:", values: ["Vertical", "Horizontal"], value :"Vertical"}, + {name: "position", title: "Position:", value: position}, + ], + on_finish: function (params) { + _this.insert_handler(params); + }, + }; + this.POP.show(settings); + } + + insert_handler(data){ + var type = data.type; + var position = parseFloat(data.position); + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + //convert units + position = this.Helper.get_internal_unit(position, units, resolution); + + var x = null; + var y = null; + if(type == 'Vertical') + x = position; + if(type == 'Horizontal') + y = position; + + //update + config.guides.push({x: x, y: y}); + + if(config.guides_enabled == false){ + //was disabled + config.guides_enabled = true; + this.Helper.setCookie('guides', 1); + alertify.warning('Guides enabled.'); + } + + config.need_render = true; + } + + update(){ + var _this = this; + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var params = []; + for(var i in config.guides){ + var guide = config.guides[i]; + + //convert units + var value = guide.x; + var value = this.Helper.get_user_unit(value, units, resolution); + + if(guide.y === null) { + params.push({name: i, title: "Vertical:", value: value}); + } + } + for(var i in config.guides){ + var guide = config.guides[i]; + + //convert units + var value = guide.y; + var value = this.Helper.get_user_unit(value, units, resolution); + + if(guide.x === null) { + params.push({name: i, title: "Horizontal:", value: value}); + } + } + + var settings = { + title: 'Update guides', + params: params, + on_finish: function (params) { + _this.update_handler(params); + }, + }; + this.POP.show(settings); + } + + update_handler(data){ + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + //update + for (var i in data) { + var key = parseInt(i); + var value = parseFloat(data[i]); + + //convert units + value = this.Helper.get_internal_unit(value, units, resolution); + + if (config.guides[key].x === null) + config.guides[key].y = value; + else + config.guides[key].x = value; + } + + //remove empty + for (var i = 0; i < config.guides.length; i++) { + if(config.guides[i].x === 0 || config.guides[i].y === 0 + || isNaN(config.guides[i].x) || isNaN( config.guides[i].y)){ + config.guides.splice(i, 1); + i--; + } + } + + config.need_render = true; + } + + remove(params) { + config.guides = []; + config.need_render = true; + } + +} + +export default View_guides_class; \ No newline at end of file diff --git a/paintplus/frontend/src/js/modules/view/ruler.js b/paintplus/frontend/src/js/modules/view/ruler.js new file mode 100644 index 0000000..096adc0 --- /dev/null +++ b/paintplus/frontend/src/js/modules/view/ruler.js @@ -0,0 +1,205 @@ +import config from './../../config.js'; +import Helper_class from './../../libs/helpers.js'; +import Base_gui_class from './../../core/base-gui.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Tools_settings_class from './../tools/settings.js'; + +var instance = null; + +class View_ruler_class { + + constructor() { + //singleton + if (instance) { + return instance; + } + instance = this; + + this.GUI = new Base_gui_class(); + this.Base_layers = new Base_layers_class(); + this.Tools_settings = new Tools_settings_class(); + this.Helper = new Helper_class(); + + this.set_events(); + } + + set_events() { + var _this = this; + + window.addEventListener('resize', function (event) { + //resize + _this.prepare_ruler(); + _this.render_ruler(); + }, false); + document.addEventListener('keydown', (event) => { + var code = event.code; + if (this.Helper.is_input(event.target)) + return; + + if (event.code == "KeyU" && event.ctrlKey != true && event.metaKey != true) { + _this.ruler(); + event.preventDefault(); + } + }, false); + } + + ruler() { + var ruler_left = document.getElementById('ruler_left'); + var ruler_top = document.getElementById('ruler_top'); + var middle_area = document.getElementById('middle_area'); + + if(config.ruler_active == false){ + //activate + config.ruler_active = true; + document.getElementById('middle_area').classList.add('has-ruler'); + ruler_left.style.display = 'block'; + ruler_top.style.display = 'block'; + + this.prepare_ruler(); + this.render_ruler(); + } + else{ + //deactivate + config.ruler_active = false; + document.getElementById('middle_area').classList.remove('has-ruler'); + ruler_left.style.display = 'none'; + ruler_top.style.display = 'none'; + } + + this.GUI.prepare_canvas(); + + config.need_render = true; + } + + prepare_ruler(){ + if(config.ruler_active == false) + return; + + var ruler_left = document.getElementById('ruler_left'); + var ruler_top = document.getElementById('ruler_top'); + var middle_area = document.getElementById('middle_area'); + + var middle_area_width = middle_area.clientWidth; + var middle_area_height = middle_area.clientHeight; + + ruler_left.width = 15; + ruler_left.height = middle_area_height - 20; + + ruler_top.width = middle_area_width - 20; + ruler_top.height = 15; + } + + render_ruler(){ + if(config.ruler_active == false) + return; + + var units = this.Tools_settings.get_setting('default_units'); + var resolution = this.Tools_settings.get_setting('resolution'); + + var ruler_left = document.getElementById('ruler_left'); + var ruler_top = document.getElementById('ruler_top'); + + var ctx_left = ruler_left.getContext("2d"); + var ctx_top = ruler_top.getContext("2d"); + + var color = '#111'; + var size = 15; + + //calc step + var step = Math.ceil(10 * config.ZOOM); + while (step < 5) { + step = step * 2; + } + while (step > 10) { + step = Math.ceil(step / 2); + } + var step_big = step * 10; + + //calc begin/end point + var begin_x = Math.max(0, ruler_top.width / 2 - config.WIDTH * config.ZOOM / 2); + var begin_y = Math.max(0, ruler_left.height / 2 - config.HEIGHT * config.ZOOM / 2); + + var end_x = Math.min(ruler_top.width, ruler_top.width / 2 + config.WIDTH * config.ZOOM / 2); + var end_y = Math.min(ruler_left.height, ruler_left.height / 2 + config.HEIGHT * config.ZOOM / 2); + + //left + ctx_left.strokeStyle = color; + ctx_left.lineWidth = 1; + ctx_left.font = "11px Arial"; + + ctx_left.clearRect(0, 0, ruler_left.width, ruler_left.height); + + ctx_left.beginPath(); + for (var i = begin_y; i < end_y; i += step) { + ctx_left.moveTo(10, i + 0.5); + ctx_left.lineTo(size, i + 0.5); + } + ctx_left.stroke(); + + ctx_left.beginPath(); + for (var i = begin_y; i <= end_y; i += step_big) { + ctx_left.moveTo(0, i + 0.5); + ctx_left.lineTo(size, i + 0.5); + + var global_pos = this.Base_layers.get_world_coords(0, i - begin_y); + var value = this.Helper.get_user_unit(global_pos.y, units, resolution); + + if(units == 'inches'){ + //more decimals value + var text = this.Helper.number_format(value, 1); + } + else{ + var text = Math.ceil(value); + } + text = text.toString(); + + //text + for (var j = 0; j < text.length; j++) { + var letter = text.charAt(j); + var line_height = 10; + ctx_left.fillText(letter, 1, i + 11 + j * line_height); + } + } + ctx_left.stroke(); + + //top + ctx_top.strokeStyle = color; + ctx_top.lineWidth = 1; + ctx_top.font = "11px Arial"; + + ctx_top.clearRect(0, 0, ruler_top.width, ruler_top.height); + + ctx_top.beginPath(); + for (var i = begin_x; i < end_x; i += step) { + var y = (i / step_big == parseInt(i / step_big)) ? 0 : step; + ctx_top.moveTo(i + 0.5, 10); + ctx_top.lineTo(i + 0.5, size); + } + ctx_top.stroke(); + + ctx_top.beginPath(); + for (var i = begin_x; i <= end_x; i += step_big) { + ctx_top.moveTo(i + 0.5, 0); + ctx_top.lineTo(i + 0.5, size); + + var global_pos = this.Base_layers.get_world_coords(i - begin_x, 0); + var value = this.Helper.get_user_unit(global_pos.x, units, resolution); + + if(units == 'inches'){ + //more decimals value + var text = this.Helper.number_format(value, 1); + } + else{ + var text = Math.ceil(value); + } + text = text.toString(); + + //text + ctx_top.fillText(text, i + 3, 9); + } + ctx_top.stroke(); + } + +} + +export default View_ruler_class; diff --git a/paintplus/frontend/src/js/modules/view/zoom.js b/paintplus/frontend/src/js/modules/view/zoom.js new file mode 100644 index 0000000..79eeb48 --- /dev/null +++ b/paintplus/frontend/src/js/modules/view/zoom.js @@ -0,0 +1,26 @@ +import GUI_preview_class from './../../core/gui/gui-preview.js'; + +class View_zoom_class { + + constructor() { + this.GUI_preview = new GUI_preview_class(); + } + + in() { + this.GUI_preview.zoom(1); + } + + out() { + this.GUI_preview.zoom(-1); + } + + original() { + this.GUI_preview.zoom(100); + } + + auto() { + this.GUI_preview.zoom_auto(); + } +} + +export default View_zoom_class; diff --git a/paintplus/frontend/src/js/services/api.js b/paintplus/frontend/src/js/services/api.js new file mode 100644 index 0000000..f8c88b3 --- /dev/null +++ b/paintplus/frontend/src/js/services/api.js @@ -0,0 +1,249 @@ +/** + * API Service for communicating with the FastAPI backend + * Handles SAM selection and AI inpainting requests + */ + +class ApiService { + constructor() { + // Backend API base URL + // In unified container: empty string (same origin) + // With separate nginx frontend: '/api' (proxied to backend) + this.baseUrl = window.API_BASE_URL || ''; + } + + /** + * Call SAM (Segment Anything Model) for smart selection + * @param {string} imageData - Base64 encoded image data + * @param {number} pointX - X coordinate of click point + * @param {number} pointY - Y coordinate of click point + * @returns {Promise<{mask: ImageData, polygon: Array}>} + */ + async smartSelect(imageData, pointX, pointY) { + const response = await fetch(`${this.baseUrl}/tools/smart-select-base64`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + image: imageData, + point_x: pointX, + point_y: pointY, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `SAM request failed: ${response.status}`); + } + + return response.json(); + } + + /** + * Call AI inpainting to edit a selected region + * @param {string} imageData - Base64 encoded image data + * @param {string} maskData - Base64 encoded mask data (white = area to edit) + * @param {string} prompt - Text prompt describing desired edit + * @param {Object} options - Additional options + * @returns {Promise<{result: string}>} - Base64 encoded result image + */ + async inpaint(imageData, maskData, prompt, options = {}) { + const response = await fetch(`${this.baseUrl}/tools/inpaint`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + image: imageData, + mask: maskData, + prompt: prompt, + negative_prompt: options.negativePrompt || '', + strength: options.strength || 0.8, + guidance_scale: options.guidanceScale || 7.5, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Inpaint request failed: ${response.status}`); + } + + return response.json(); + } + + /** + * Remove background from image using AI (BEN2 / BiRefNet-HR / U2Net / rembg) + * @param {string} imageData - Base64 encoded image data + * @param {string} [model='auto'] - "auto", "ben2", "birefnet-hr", "u2net", or "rembg" + * @returns {Promise<{result: string, width: number, height: number, method: string}>} - Base64 encoded result with transparency + */ + async removeBackground(imageData, model = 'auto') { + const response = await fetch(`${this.baseUrl}/tools/remove-background-base64`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + image: imageData, + model: model, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Remove background request failed: ${response.status}`); + } + + return response.json(); + } + + /** + * AI erase using LaMa (local, no API key needed) + * @param {string} imageData - Base64 encoded image + * @param {string} maskData - Base64 encoded mask (white = erase) + * @returns {Promise<{result: string, method: string}>} + */ + async erase(imageData, maskData) { + const response = await fetch(`${this.baseUrl}/api/erase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: imageData, mask: maskData }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Erase request failed: ${response.status}`); + } + return response.json(); + } + + /** + * Text-to-image via remote provider + * @param {string} prompt + * @param {Object} options - width, height, negativePrompt, steps, cfgScale, model + * @returns {Promise<{result: string}>} + */ + async textToImage(prompt, options = {}) { + const response = await fetch(`${this.baseUrl}/api/generate/txt2img`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + width: options.width || 1024, + height: options.height || 1024, + negative_prompt: options.negativePrompt || '', + steps: options.steps || 30, + cfg_scale: options.cfgScale || 7.5, + model: options.model || null, + seed: options.seed || 0, + }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Text-to-image failed: ${response.status}`); + } + return response.json(); + } + + /** + * Image-to-image via remote provider + * @param {string} imageData - Base64 encoded image + * @param {string} prompt + * @param {Object} options + * @returns {Promise<{result: string}>} + */ + async imageToImage(imageData, prompt, options = {}) { + const response = await fetch(`${this.baseUrl}/api/generate/img2img`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageData, + prompt, + strength: options.strength || 0.75, + negative_prompt: options.negativePrompt || '', + steps: options.steps || 30, + cfg_scale: options.cfgScale || 7.5, + model: options.model || null, + }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Image-to-image failed: ${response.status}`); + } + return response.json(); + } + + /** + * Inpaint with prompt via remote provider + * @param {string} imageData - Base64 + * @param {string} maskData - Base64 + * @param {string} prompt + * @param {Object} options + * @returns {Promise<{result: string}>} + */ + async remoteInpaint(imageData, maskData, prompt, options = {}) { + const response = await fetch(`${this.baseUrl}/api/inpaint/remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageData, + mask: maskData, + prompt, + negative_prompt: options.negativePrompt || '', + steps: options.steps || 30, + cfg_scale: options.cfgScale || 7.5, + model: options.model || null, + }), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(error.detail || `Remote inpaint failed: ${response.status}`); + } + return response.json(); + } + + /** + * Fetch backend capabilities (local tools available, remote provider status). + * @returns {Promise} + */ + async getConfig() { + try { + const response = await fetch(`${this.baseUrl}/api/config`); + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } + } + + /** + * Fetch GPU status: hardware, feature flags, and selected models per operation. + * Only meaningful when AI_PROVIDER=local_gpu. + * @returns {Promise} + */ + async getGpuStatus() { + try { + const response = await fetch(`${this.baseUrl}/api/gpu/status`); + if (!response.ok) return null; + return response.json(); + } catch { + return null; + } + } + + /** + * Health check for the backend + * @returns {Promise} + */ + async healthCheck() { + try { + const response = await fetch(`${this.baseUrl}/health`); + return response.ok; + } catch { + return false; + } + } +} + +// Singleton instance +const apiService = new ApiService(); +export default apiService; diff --git a/paintplus/frontend/src/js/tools/ai_edit.js b/paintplus/frontend/src/js/tools/ai_edit.js new file mode 100644 index 0000000..5a5fe39 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ai_edit.js @@ -0,0 +1,586 @@ +/** + * AI Edit — unified smart selection + inpainting tool. + * + * Workflow: + * 1. CLICK mode (default): click any object → SAM auto-selects it (red overlay) + * • Alt+click → subtract from selection (deselect over-selected area) + * • Multiple clicks accumulate on the mask + * 2. BRUSH + / BRUSH − tabs: paint to add or erase from the mask by hand + * (refine what SAM missed or got wrong) + * 3. Action bar: Erase | Replace… | Upscale | Expand | Clear + * + * SAM model (~375 MB) auto-downloads on first click; progress shown inline. + * Falls back gracefully to brush-only if SAM is unavailable. + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_layers_class from './../core/base-layers.js'; +import Base_tools_class from './../core/base-tools.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +const BRUSH_DEFAULT = 30; +const OVERLAY_COLOR = 'rgba(255, 55, 55, 0.50)'; + +class Tools_ai_edit_class extends Base_tools_class { + + constructor() { + super(); + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.name = 'ai_edit'; + this.title = 'AI Edit'; + // interaction state + this._mode = 'sam'; // 'sam' | 'brush_add' | 'brush_sub' + this._painting = false; + this._samWorking = false; + this._isRunning = false; + this._hasMask = false; + // DOM elements + this._maskCanvas = null; + this._maskCtx = null; + this._overlayEl = null; + this._panel = null; + } + + // ── Tool lifecycle ──────────────────────────────────────────────────────── + + load() { + this.default_events(); + } + + on_activate() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + this._initMask(); + this._mountOverlay(); + this._mountPanel(); + } + + on_leave() { + this._removeOverlay(); + this._removePanel(); + this._painting = false; + } + + // ── Input routing ───────────────────────────────────────────────────────── + + mousedown(e) { + if (config.TOOL.name !== this.name) return; + var mouse = this.get_mouse_info(e); + if (!mouse.click_valid) return; + if (!config.layer || config.layer.type !== 'image') return; + if (this._mode === 'sam') { + this._handleSamClick(e, mouse); + } else { + this._painting = true; + this._brushPaint(mouse); + } + } + + mousemove(e) { + if (config.TOOL.name !== this.name) return; + if (this._mode !== 'sam' && this._painting) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag) this._brushPaint(mouse); + } + } + + mouseup(e) { + if (config.TOOL.name !== this.name) return; + if (this._painting) { + this._painting = false; + if (this._hasMask) this._showActions(); + } + } + + // ── Coordinate mapping — uses miniPaint's get_mouse_info ───────────────── + // get_mouse_info returns { x, y } already in canvas/layer coordinates. + // We still need to know the display scale to size brush strokes on the overlay. + + _mouseToImage(mouse) { + // mouse.x/y are already in original image coords from get_mouse_info + const scaleX = (config.WIDTH * config.ZOOM) / config.layer.width_original; + const scaleY = (config.HEIGHT * config.ZOOM) / config.layer.height_original; + return { ix: mouse.x, iy: mouse.y, scaleX, scaleY }; + } + + // ── SAM click selection ─────────────────────────────────────────────────── + + async _handleSamClick(e, mouse) { + if (this._samWorking) return; + const coords = this._mouseToImage(mouse); + + const label = e.altKey ? 0 : 1; // alt = exclude, normal = include + const x = Math.round(coords.ix); + const y = Math.round(coords.iy); + + // Clamp to image bounds + const w = config.layer.width_original; + const h = config.layer.height_original; + if (x < 0 || y < 0 || x >= w || y >= h) return; + + this._samWorking = true; + this._setSamCursor('wait'); + + // Collect any existing points for multi-click accumulation + if (!this._samPoints) this._samPoints = []; + if (!this._samLabels) this._samLabels = []; + this._samPoints.push([x, y]); + this._samLabels.push(label); + + try { + const imageB64 = this._getLayerB64(); + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/segment/point`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + points: this._samPoints, + labels: this._samLabels, + }), + }); + + if (r.status === 503) { + // SAM model downloading — poll and retry + const data = await r.json().catch(() => ({})); + await this._waitForSamModel(data.detail || ''); + // Remove the point we just added so user can retry cleanly + this._samPoints.pop(); + this._samLabels.pop(); + this._samWorking = false; + this._setSamCursor('crosshair'); + return; + } + + if (!r.ok) { + const err = await r.json().catch(() => ({})); + throw new Error(err.detail || 'SAM failed'); + } + + const data = await r.json(); + await this._applySamMask(data.mask, label === 0); + this._hasMask = true; + this._showActions(); + + } catch (err) { + alertify.error('SAM failed: ' + (err.message || err)); + // Pop failed point + this._samPoints.pop(); + this._samLabels.pop(); + } + + this._samWorking = false; + this._setSamCursor('crosshair'); + } + + async _applySamMask(maskB64, isSubtract) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + // Draw SAM mask onto our persistent mask canvas + const tmp = document.createElement('canvas'); + tmp.width = this._maskCanvas.width; + tmp.height = this._maskCanvas.height; + const tctx = tmp.getContext('2d'); + tctx.drawImage(img, 0, 0, tmp.width, tmp.height); + + if (isSubtract) { + // Erase mask where SAM says to subtract + this._maskCtx.globalCompositeOperation = 'destination-out'; + this._maskCtx.drawImage(tmp, 0, 0); + this._maskCtx.globalCompositeOperation = 'source-over'; + } else { + this._maskCtx.drawImage(tmp, 0, 0); + } + this._redrawOverlay(); + resolve(); + }; + img.src = 'data:image/png;base64,' + maskB64; + }); + } + + async _waitForSamModel(detail) { + // SAM model is downloading — show progress bar and poll + return new Promise((resolve) => { + alertify.message( + `
    Downloading SAM model (~375 MB)…
    + + 0%
    + This happens once — click the object again when done. +
    `, 0 + ); + const poll = setInterval(async () => { + try { + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/segment/install-status`); + if (!r.ok) return; + const s = await r.json(); + const bar = document.getElementById('sam-dl-progress'); + const pct = document.getElementById('sam-dl-pct'); + if (bar) bar.value = s.progress || 0; + if (pct) pct.textContent = `${s.progress || 0}%`; + if (s.state === 'done' || s.model_ready) { + clearInterval(poll); + alertify.dismissAll(); + alertify.success('SAM model ready — click the object now.'); + resolve(); + } else if (s.state === 'failed') { + clearInterval(poll); + alertify.dismissAll(); + alertify.error('SAM model download failed. Use Brush mode instead.'); + resolve(); + } + } catch { /* keep polling */ } + }, 1500); + }); + } + + _setSamCursor(cursor) { + const canvasEl = document.getElementById('canvas_minipaint') || document.querySelector('canvas'); + if (canvasEl) canvasEl.style.cursor = cursor; + } + + // ── Brush painting ──────────────────────────────────────────────────────── + + _brushPaint(mouse) { + const { ix, iy, scaleX, scaleY } = this._mouseToImage(mouse); + const r = (config.tools[this.name]?.size ?? BRUSH_DEFAULT) / 2; + + // Paint on mask canvas (image coords) + this._maskCtx.globalCompositeOperation = + this._mode === 'brush_sub' ? 'destination-out' : 'source-over'; + this._maskCtx.fillStyle = '#ffffff'; + this._maskCtx.beginPath(); + this._maskCtx.arc(ix, iy, r, 0, Math.PI * 2); + this._maskCtx.fill(); + this._maskCtx.globalCompositeOperation = 'source-over'; + + // Mirror on overlay (display coords) + const oc = this._overlayEl; + if (!oc) return; + const oct = oc.getContext('2d'); + const ox = ix * scaleX + config.layer.x * config.ZOOM; + const oy = iy * scaleY + config.layer.y * config.ZOOM; + const or_ = r * scaleX; + + if (this._mode === 'brush_sub') { + oct.globalCompositeOperation = 'destination-out'; + oct.fillStyle = '#000'; + } else { + oct.globalCompositeOperation = 'source-over'; + oct.fillStyle = OVERLAY_COLOR; + } + oct.beginPath(); + oct.arc(ox, oy, or_, 0, Math.PI * 2); + oct.fill(); + oct.globalCompositeOperation = 'source-over'; + + this._hasMask = true; + } + + // ── Overlay ─────────────────────────────────────────────────────────────── + + _mountOverlay() { + this._removeOverlay(); + const base = document.getElementById('canvas_minipaint') || document.querySelector('canvas'); + if (!base) return; + const oc = document.createElement('canvas'); + oc.id = 'ai_edit_overlay'; + oc.width = base.offsetWidth; + oc.height = base.offsetHeight; + Object.assign(oc.style, { + position: 'absolute', top: base.offsetTop + 'px', left: base.offsetLeft + 'px', + pointerEvents: 'none', zIndex: '50', + }); + base.parentElement.appendChild(oc); + this._overlayEl = oc; + } + + _redrawOverlay() { + if (!this._overlayEl || !this._maskCanvas) return; + const oc = this._overlayEl; + const oct = oc.getContext('2d'); + oct.clearRect(0, 0, oc.width, oc.height); + + // Scale mask to overlay size and tint red + const tmp = document.createElement('canvas'); + tmp.width = oc.width; + tmp.height = oc.height; + const tctx = tmp.getContext('2d'); + tctx.drawImage(this._maskCanvas, 0, 0, oc.width, oc.height); + + // Multiply white mask pixels → red tint using composite + oct.globalCompositeOperation = 'source-over'; + oct.fillStyle = OVERLAY_COLOR; + oct.fillRect(0, 0, oc.width, oc.height); + oct.globalCompositeOperation = 'destination-in'; + oct.drawImage(tmp, 0, 0); + oct.globalCompositeOperation = 'source-over'; + } + + _removeOverlay() { + if (this._overlayEl) { this._overlayEl.remove(); this._overlayEl = null; } + } + + // ── Panel ───────────────────────────────────────────────────────────────── + + _mountPanel() { + this._removePanel(); + const panel = document.createElement('div'); + panel.id = 'ai_edit_panel'; + Object.assign(panel.style, { + position: 'fixed', bottom: '72px', left: '50%', transform: 'translateX(-50%)', + background: '#1a1a1a', border: '1px solid #3a3a3a', borderRadius: '12px', + padding: '10px 14px', display: 'flex', flexDirection: 'column', + gap: '8px', zIndex: '9999', boxShadow: '0 6px 24px rgba(0,0,0,0.6)', + fontFamily: 'sans-serif', fontSize: '13px', color: '#eee', + userSelect: 'none', minWidth: '460px', + }); + panel.innerHTML = this._panelHTML(); + document.body.appendChild(panel); + this._panel = panel; + this._wirePanel(); + } + + _panelHTML() { + return ` + + + +
    + Select: + + + +
    + Click an object to select it. Alt+click to deselect. +
    + + +
    + Then: + +
    + + + +
    + + + +
    `; + } + + _showActions() { + // No-op — actions are always visible; just a hook for future animation + } + + _wirePanel() { + if (!this._panel) return; + const _this = this; + const hints = { + sam: 'Click an object to select it. Alt+click to deselect an area.', + brush_add: 'Paint over areas to add them to the selection.', + brush_sub: 'Paint over areas to remove them from the selection.', + }; + + // Mode buttons + this._panel.querySelectorAll('[data-mode]').forEach(btn => { + btn.addEventListener('click', () => { + _this._mode = btn.dataset.mode; + _this._panel.querySelectorAll('[data-mode]').forEach(b => + b.classList.toggle('active', b === btn)); + const hint = _this._panel.querySelector('#aie-hint'); + if (hint) hint.textContent = hints[_this._mode] || ''; + _this._setSamCursor(_this._mode === 'sam' ? 'crosshair' : 'cell'); + }); + }); + + // Action buttons + this._panel.querySelectorAll('[data-action]').forEach(btn => { + btn.addEventListener('click', () => { + const a = btn.dataset.action; + if (a === 'erase') _this._doErase(); + if (a === 'replace') _this._toggleReplace(); + if (a === 'upscale') _this._doUpscale(); + if (a === 'expand') _this._doExpand(); + if (a === 'clear') _this._doClear(); + }); + }); + + // Replace prompt + const goBtn = this._panel.querySelector('#aie-go'); + const promptEl = this._panel.querySelector('#aie-prompt'); + if (goBtn && promptEl) { + goBtn.addEventListener('click', () => _this._doReplace(promptEl.value.trim())); + promptEl.addEventListener('keydown', e => { + if (e.key === 'Enter') _this._doReplace(promptEl.value.trim()); + }); + } + } + + _toggleReplace() { + const p = this._panel; + if (!p) return; + const promptEl = p.querySelector('#aie-prompt'); + const goBtn = p.querySelector('#aie-go'); + const shown = promptEl.style.display !== 'none'; + promptEl.style.display = shown ? 'none' : 'inline-block'; + goBtn.style.display = shown ? 'none' : 'inline-block'; + if (!shown) setTimeout(() => promptEl.focus(), 40); + } + + _removePanel() { + if (this._panel) { this._panel.remove(); this._panel = null; } + } + + // ── Mask + image helpers ────────────────────────────────────────────────── + + _initMask() { + const w = config.layer.width_original; + const h = config.layer.height_original; + this._maskCanvas = document.createElement('canvas'); + this._maskCanvas.width = w; + this._maskCanvas.height = h; + this._maskCtx = this._maskCanvas.getContext('2d'); + this._hasMask = false; + this._samPoints = []; + this._samLabels = []; + } + + _getLayerB64() { + const layer = config.layer; + const c = document.createElement('canvas'); + c.width = layer.width_original; c.height = layer.height_original; + c.getContext('2d').drawImage(layer.link, 0, 0); + return c.toDataURL('image/png').split(',')[1]; + } + + _requireMask() { + if (!this._hasMask) { + alertify.error('Select an area first — click an object or use Brush.'); + return false; + } + return true; + } + + _applyResult(resultB64, label) { + const img = new Image(); + img.onload = () => { + const rc = document.createElement('canvas'); + rc.width = img.naturalWidth; rc.height = img.naturalHeight; + rc.getContext('2d').drawImage(img, 0, 0); + app.State.do_action( + new app.Actions.Bundle_action('ai_edit', label, [ + new app.Actions.Update_layer_image_action(rc) + ]) + ); + alertify.dismissAll(); + alertify.success(label + ' applied.'); + this._isRunning = false; + this._doClear(); + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result image.'); + this._isRunning = false; + }; + img.src = 'data:image/png;base64,' + resultB64; + } + + // ── Actions ─────────────────────────────────────────────────────────────── + + async _doErase() { + if (!this._requireMask() || this._isRunning) return; + this._isRunning = true; + alertify.message('Erasing…', 0); + try { + const maskB64 = this._maskCanvas.toDataURL('image/png').split(',')[1]; + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/erase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: this._getLayerB64(), mask: maskB64 }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed'); + this._applyResult((await r.json()).result, 'Erase'); + } catch (err) { + alertify.dismissAll(); + alertify.error('Erase failed: ' + (err.message || err)); + this._isRunning = false; + } + } + + async _doReplace(prompt) { + if (!this._requireMask() || this._isRunning) return; + if (!prompt) { alertify.error('Describe what you want to put there.'); return; } + this._isRunning = true; + alertify.message(`Replacing: "${prompt}"…`, 0); + try { + const maskB64 = this._maskCanvas.toDataURL('image/png').split(',')[1]; + const base = window.API_BASE_URL || ''; + const r = await fetch(`${base}/api/inpaint/remote`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ image: this._getLayerB64(), mask: maskB64, prompt }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Failed'); + this._applyResult((await r.json()).result, `Replace: ${prompt}`); + } catch (err) { + alertify.dismissAll(); + alertify.error('Replace failed: ' + (err.message || err)); + this._isRunning = false; + } + } + + _doUpscale() { + import('./../modules/image/upscale.js').then(m => new m.default().upscale()); + } + + _doExpand() { + import('./../modules/generate/outpaint.js').then(m => new m.default().outpaint()); + } + + _doClear() { + if (this._maskCtx) + this._maskCtx.clearRect(0, 0, this._maskCanvas.width, this._maskCanvas.height); + if (this._overlayEl) + this._overlayEl.getContext('2d').clearRect(0, 0, this._overlayEl.width, this._overlayEl.height); + const p = this._panel; + if (p) { + const promptEl = p.querySelector('#aie-prompt'); + const goBtn = p.querySelector('#aie-go'); + if (promptEl) { promptEl.style.display = 'none'; promptEl.value = ''; } + if (goBtn) goBtn.style.display = 'none'; + } + this._hasMask = false; + this._samPoints = []; + this._samLabels = []; + } +} + +export default Tools_ai_edit_class; diff --git a/paintplus/frontend/src/js/tools/ai_inpaint.js b/paintplus/frontend/src/js/tools/ai_inpaint.js new file mode 100644 index 0000000..54e2f56 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ai_inpaint.js @@ -0,0 +1,465 @@ +/** + * AI Inpaint Tool - Edit selected regions using AI with text prompts + * Works with any selection tool: Smart Select, Magic Wand, Lasso, Ellipse Select, or Selection + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; + +class Ai_inpaint_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.POP = new Dialog_class(); + this.ctx = ctx; + this.name = 'ai_inpaint'; + this.isProcessing = false; + } + + load() { + // No mouse events needed - this tool uses a dialog + } + + on_activate() { + this.showInpaintDialog(); + } + + /** + * Show the inpainting dialog + */ + showInpaintDialog() { + var _this = this; + + // Check if we have a selection from any selection tool + // All selection tools (Smart Select, Magic Wand, Lasso, Ellipse Select) store their mask in window.smartSelectMask + var hasMask = window.smartSelectMask != null && window.smartSelectMask.canvas != null; + var hasRectSelection = this.getRectSelection() != null; + + if (!hasMask && !hasRectSelection) { + alertify.warning('No selection found. Use Smart Select, Magic Wand, Lasso, Ellipse Select, or Selection tool first.'); + return; + } + + var settings = { + title: 'AI Edit Selection', + params: [ + { + name: "mode", + title: "Edit Mode:", + value: "inpaint", + values: ["inpaint", "transform"] + }, + { + name: "prompt", + title: "AI Inpaint - Describe replacement:", + type: "textarea", + value: "", + placeholder: "AI will REPLACE the selection with what you describe.\nExamples: 'a red rose', 'empty background', 'blue sky'" + }, + { + name: "negative_prompt", + title: "What to avoid (optional):", + value: "", + placeholder: "e.g., 'blurry, distorted, low quality'" + }, + { + name: "strength", + title: "AI Edit Strength:", + type: "range", + value: 80, + range: [1, 100], + step: 1 + }, + { + name: "scale", + title: "Transform - Scale %:", + type: "range", + value: 100, + range: [10, 200], + step: 5 + } + ], + on_load: function(el) { + // Add info text + var infoDiv = document.createElement('div'); + infoDiv.className = 'ai-inpaint-info'; + infoDiv.innerHTML = '

    ' + + 'Inpaint Mode: AI replaces the selected area with generated content.
    ' + + 'Transform Mode: Scale, shrink, or enlarge the selection without AI.
    ' + + 'Tip: To shrink something by 35%, use Transform mode with Scale at 65%.

    '; + + var dialogContent = el.querySelector('.dialog_content'); + if (dialogContent && dialogContent.firstChild) { + dialogContent.insertBefore(infoDiv, dialogContent.firstChild); + } + }, + on_finish: async function (params) { + if (params.mode === 'transform') { + await _this.executeTransform(params); + } else { + await _this.executeInpaint(params); + } + }, + }; + + this.POP.show(settings); + } + + /** + * Execute transform operation (scale without AI) + */ + async executeTransform(params) { + if (this.isProcessing) { + alertify.warning('Already processing... please wait'); + return; + } + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + var maskCanvas = window.smartSelectMask?.canvas; + if (!maskCanvas) { + alertify.error('No selection mask found'); + return; + } + + this.isProcessing = true; + alertify.message('Transforming selection...'); + + try { + var layer = config.layer; + var scale = params.scale / 100; + + // Get mask bounds + var maskCtx = maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height); + var minX = maskCanvas.width, minY = maskCanvas.height; + var maxX = 0, maxY = 0; + + for (var y = 0; y < maskCanvas.height; y++) { + for (var x = 0; x < maskCanvas.width; x++) { + var i = (y * maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (maxX <= minX || maxY <= minY) { + throw new Error('Selection is too small'); + } + + var selWidth = maxX - minX + 1; + var selHeight = maxY - minY + 1; + var centerX = minX + selWidth / 2; + var centerY = minY + selHeight / 2; + + // Extract selected pixels + var extractCanvas = document.createElement('canvas'); + extractCanvas.width = layer.width_original; + extractCanvas.height = layer.height_original; + var extractCtx = extractCanvas.getContext('2d'); + extractCtx.drawImage(layer.link, 0, 0); + extractCtx.globalCompositeOperation = 'destination-in'; + extractCtx.drawImage(maskCanvas, 0, 0); + + // Create result canvas + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = layer.width_original; + resultCanvas.height = layer.height_original; + var resultCtx = resultCanvas.getContext('2d'); + + // Draw original image + resultCtx.drawImage(layer.link, 0, 0); + + // Remove original selection (create hole) + resultCtx.globalCompositeOperation = 'destination-out'; + resultCtx.drawImage(maskCanvas, 0, 0); + + // Calculate scaled dimensions + var newWidth = selWidth * scale; + var newHeight = selHeight * scale; + var newX = centerX - newWidth / 2; + var newY = centerY - newHeight / 2; + + // Draw scaled selection back + resultCtx.globalCompositeOperation = 'source-over'; + + // Create temp canvas for just the selection + var selCanvas = document.createElement('canvas'); + selCanvas.width = selWidth; + selCanvas.height = selHeight; + var selCtx = selCanvas.getContext('2d'); + selCtx.drawImage(extractCanvas, minX, minY, selWidth, selHeight, 0, 0, selWidth, selHeight); + + // Draw scaled + resultCtx.drawImage(selCanvas, 0, 0, selWidth, selHeight, newX, newY, newWidth, newHeight); + + // Apply result + app.State.do_action( + new app.Actions.Bundle_action('transform_selection', 'Transform Selection', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + + // Clear selection + window.smartSelectMask = null; + config.need_render = true; + + alertify.success('Transform complete! Selection scaled to ' + params.scale + '%'); + + } catch (error) { + console.error('Transform error:', error); + alertify.error('Transform failed: ' + error.message); + } finally { + this.isProcessing = false; + } + } + + /** + * Execute the inpainting operation + */ + async executeInpaint(params) { + if (this.isProcessing) { + alertify.warning('Already processing... please wait'); + return; + } + + if (!params.prompt || params.prompt.trim() === '') { + alertify.error('Please enter a prompt describing what you want'); + return; + } + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer'); + return; + } + + this.isProcessing = true; + alertify.message('AI is generating... this may take a moment'); + + try { + // Get image data + var imageData = this.getLayerImageData(); + + // Get mask data (from Smart Select or rectangular selection) + var maskData = this.getMaskData(); + + if (!maskData) { + throw new Error('No valid selection/mask found'); + } + + // Call inpaint API + var result = await apiService.inpaint( + imageData, + maskData, + params.prompt, + { + negativePrompt: params.negative_prompt || '', + strength: params.strength / 100 + } + ); + + // Apply result to layer + await this.applyResult(result.result); + + alertify.success('Inpainting complete!'); + + } catch (error) { + console.error('Inpaint error:', error); + alertify.error('Inpainting failed: ' + error.message); + } finally { + this.isProcessing = false; + } + } + + /** + * Get the current layer's image data as base64 + */ + getLayerImageData() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + ctx.drawImage(config.layer.link, 0, 0); + + return canvas.toDataURL('image/png').split(',')[1]; + } + + /** + * Get mask data - either from Smart Select or rectangular selection + */ + getMaskData() { + // First try Smart Select mask + if (window.smartSelectMask && window.smartSelectMask.canvas) { + var maskCanvas = window.smartSelectMask.canvas; + return maskCanvas.toDataURL('image/png').split(',')[1]; + } + + // Fall back to rectangular selection + var selection = this.getRectSelection(); + if (selection) { + return this.createRectMask(selection); + } + + return null; + } + + /** + * Get rectangular selection from miniPaint's selection tool + */ + getRectSelection() { + // Try to get selection from selection tool + var Selection = null; + try { + var GUI_tools = app.GUI?.GUI_tools || this.Base_layers?.Base_gui?.GUI_tools; + if (GUI_tools && GUI_tools.tools_modules && GUI_tools.tools_modules.selection) { + Selection = GUI_tools.tools_modules.selection.object; + } + } catch (e) { + // Selection tool not available + } + + if (Selection && Selection.selection && + Selection.selection.width > 0 && Selection.selection.height > 0) { + return Selection.selection; + } + + return null; + } + + /** + * Create a white rectangle mask from selection coordinates + */ + createRectMask(selection) { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + + // Fill with black (unselected) + ctx.fillStyle = '#000000'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Calculate selection position relative to layer + var x = selection.x - config.layer.x; + var y = selection.y - config.layer.y; + var width = selection.width; + var height = selection.height; + + // Scale to original image size + var scaleX = config.layer.width_original / config.layer.width; + var scaleY = config.layer.height_original / config.layer.height; + + x = x * scaleX; + y = y * scaleY; + width = width * scaleX; + height = height * scaleY; + + // Draw white rectangle (selected area) + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(x, y, width, height); + + return canvas.toDataURL('image/png').split(',')[1]; + } + + /** + * Apply the inpainted result to the current layer + */ + async applyResult(resultBase64) { + var _this = this; + + return new Promise((resolve, reject) => { + var img = new Image(); + img.onload = function() { + // Create canvas with result + var canvas = document.createElement('canvas'); + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + var ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + + // Update layer through action system for undo support + app.State.do_action( + new app.Actions.Bundle_action('ai_inpaint', 'AI Inpaint', [ + new app.Actions.Update_layer_image_action(canvas) + ]) + ); + + // Clear the smart select mask + window.smartSelectMask = null; + + config.need_render = true; + resolve(); + }; + img.onerror = function() { + reject(new Error('Failed to load result image')); + }; + img.src = 'data:image/png;base64,' + resultBase64; + }); + } + + render_overlay(ctx) { + // Show visual indicator if there's a selection ready for inpainting + if (window.smartSelectMask && window.smartSelectMask.canvas) { + // Draw a subtle border around the tool indicating mask is ready + ctx.save(); + ctx.strokeStyle = '#00ff00'; + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + // Get mask bounds + var maskCanvas = window.smartSelectMask.canvas; + var maskCtx = maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height); + + var minX = maskCanvas.width, minY = maskCanvas.height; + var maxX = 0, maxY = 0; + + for (var y = 0; y < maskCanvas.height; y += 4) { // Sample every 4th pixel for speed + for (var x = 0; x < maskCanvas.width; x += 4) { + var i = (y * maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (maxX > minX && maxY > minY) { + ctx.strokeRect( + config.layer.x + minX * scaleX, + config.layer.y + minY * scaleY, + (maxX - minX) * scaleX, + (maxY - minY) * scaleY + ); + } + + ctx.restore(); + } + } +} + +export default Ai_inpaint_class; diff --git a/paintplus/frontend/src/js/tools/ai_lama_erase.js b/paintplus/frontend/src/js/tools/ai_lama_erase.js new file mode 100644 index 0000000..e4616b6 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ai_lama_erase.js @@ -0,0 +1,199 @@ +/** + * AI Magic Eraser — paint a mask with a brush, send to LaMa backend, apply result. + * Works locally (no API key). GPU auto-detected; CPU fallback always available. + * + * Workflow: + * 1. User paints over the object to erase (red overlay shows the mask) + * 2. On mouseup, POST image + mask to /api/erase + * 3. Result replaces the current layer canvas + * + * Registered as tool name: "ai_lama_erase" + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; + +class Ai_lama_erase_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'ai_lama_erase'; + + this.isDrawing = false; + this.isProcessing = false; + + // Off-screen canvas used to accumulate the painted mask + this.maskCanvas = null; + this.maskCtx = null; + } + + load() { + var _this = this; + document.addEventListener('mousedown', function (e) { _this.mousedown(e); }); + document.addEventListener('mousemove', function (e) { _this.mousemove(e); }); + document.addEventListener('mouseup', function (e) { _this.mouseup(e); }); + document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false }); + document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false }); + document.addEventListener('touchend', function (e) { _this.mouseup(e); }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (!mouse.click_valid) return; + if (config.TOOL.name !== this.name) return; + if (this.isProcessing) return; + + if (config.layer.type !== 'image') { + alertify.error('This layer must contain an image.'); + return; + } + + this._initMask(); + this.isDrawing = true; + this._paint(mouse); + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name !== this.name) return; + var mouse = this.get_mouse_info(e); + this._paint(mouse); + } + + mouseup(e) { + if (!this.isDrawing) return; + this.isDrawing = false; + if (config.TOOL.name !== this.name) return; + this._applyErase(); + } + + // ── Private ────────────────────────────────────────────────────────────── + + _initMask() { + var w = config.layer.width_original; + var h = config.layer.height_original; + + if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) { + this.maskCanvas = document.createElement('canvas'); + this.maskCanvas.width = w; + this.maskCanvas.height = h; + this.maskCtx = this.maskCanvas.getContext('2d'); + } + this.maskCtx.clearRect(0, 0, w, h); + } + + _paint(mouse) { + var params = this.getParams(); + var size = params.size || 30; + + // Map screen coords → layer-original coords + var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width')); + var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height')); + + this.maskCtx.beginPath(); + this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2); + this.maskCtx.fillStyle = '#ffffff'; + this.maskCtx.fill(); + + // Show red overlay on screen so user can see the painted area + this._renderOverlay(lx, ly, size); + } + + _renderOverlay(lx, ly, size) { + // Draw a translucent red circle on the main canvas for visual feedback + var scale = config.ZOOM / 100; + var sx = config.layer.x * scale + lx * scale; + var sy = config.layer.y * scale + ly * scale; + var sRadius = (size / 2) * scale; + + var mainCtx = document.getElementById('canvas_temp') + ? document.getElementById('canvas_temp').getContext('2d') + : null; + if (!mainCtx) return; + + mainCtx.save(); + mainCtx.beginPath(); + mainCtx.arc(sx, sy, sRadius, 0, Math.PI * 2); + mainCtx.fillStyle = 'rgba(255, 60, 60, 0.4)'; + mainCtx.fill(); + mainCtx.restore(); + } + + async _applyErase() { + if (this.isProcessing) return; + + // Check if any mask pixels were painted + var maskData = this.maskCtx.getImageData( + 0, 0, this.maskCanvas.width, this.maskCanvas.height + ); + var hasPixels = maskData.data.some((v, i) => i % 4 === 3 && v > 0); + if (!hasPixels) return; + + this.isProcessing = true; + alertify.message('AI erasing... please wait', 0); + + try { + // Get current layer as PNG base64 + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + var lctx = layerCanvas.getContext('2d'); + lctx.drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + + // Get mask as PNG base64 + var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1]; + + // Call backend + var result = await apiService.erase(imageB64, maskB64); + + // Apply result back to layer + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = config.layer.width_original; + resultCanvas.height = config.layer.height_original; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('ai_lama_erase', 'AI Erase', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + + alertify.dismissAll(); + alertify.success('Erased! (' + result.method + ')'); + this.isProcessing = false; + this._clearOverlay(); + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('AI erase failed: ' + (err.message || err)); + this.isProcessing = false; + } + } + + _clearOverlay() { + var canvas = document.getElementById('canvas_temp'); + if (canvas) { + canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + } + } +} + +export default Ai_lama_erase_class; diff --git a/paintplus/frontend/src/js/tools/ai_replace_selection.js b/paintplus/frontend/src/js/tools/ai_replace_selection.js new file mode 100644 index 0000000..f601ac0 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ai_replace_selection.js @@ -0,0 +1,218 @@ +/** + * AI Replace Selection — pick any selection (Smart Select, Magic Wand, Lasso, Brush Select), + * describe what should go there, remote provider fills it in. + * + * Requires a configured remote provider (InvokeAI / ComfyUI / OpenAI). + * Registered as tool name: "ai_replace_selection" + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; +import { getCapabilities } from './../api/capabilities.js'; + +class Ai_replace_selection_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.ctx = ctx; + this.name = 'ai_replace_selection'; + this.isProcessing = false; + } + + load() {} + + async on_activate() { + var caps = await getCapabilities(); + if (!caps.remote || !caps.remote.healthy) { + alertify.error( + 'Replace Selection requires a remote AI provider. ' + + 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + ); + return; + } + + var hasMask = window.smartSelectMask?.canvas != null; + var hasRect = this._getRectSelection() != null; + + if (!hasMask && !hasRect) { + alertify.warning( + 'No selection found. Use Smart Select, Magic Wand, Lasso, ' + + 'Ellipse Select, or Brush Select first, then activate this tool.' + ); + return; + } + + this._showDialog(caps.remote.provider); + } + + // ── Private ────────────────────────────────────────────────────────────── + + _getRectSelection() { + if (!config.layer) return null; + var sel = config.layer.selection; + if (!sel) return null; + var { x, y, width, height } = sel; + if (!width || !height) return null; + return { x, y, width, height }; + } + + _showDialog(providerName) { + var _this = this; + + this.POP.show({ + title: 'AI Replace Selection', + params: [ + { + name: 'prompt', + title: 'Describe what to place here:', + type: 'textarea', + value: '', + placeholder: "e.g. 'a blooming red rose', 'dark polished wood', 'a smiling golden retriever'", + }, + { + name: 'negative_prompt', + title: 'Avoid (optional):', + value: '', + placeholder: 'blurry, distorted, low quality', + }, + { + name: 'steps', + title: 'Steps:', + type: 'range', + value: 30, + range: [10, 60], + step: 5, + }, + { + name: 'cfg_scale', + title: 'Prompt strength:', + type: 'range', + value: 75, + range: [10, 100], + step: 5, + }, + ], + on_finish: function (params) { + if (!params.prompt || !params.prompt.trim()) { + alertify.warning('Please enter a description.'); + return; + } + _this._run(params); + }, + }); + } + + async _run(params) { + if (this.isProcessing) return; + if (config.layer.type !== 'image') { + alertify.error('Current layer must be an image.'); + return; + } + + this.isProcessing = true; + alertify.message('Replacing selection... please wait', 0); + + try { + // Build mask canvas from current selection + var maskCanvas = await this._buildMaskCanvas(); + if (!maskCanvas) { + alertify.dismissAll(); + alertify.error('Could not build selection mask.'); + this.isProcessing = false; + return; + } + + // Get layer as PNG + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + var maskB64 = maskCanvas.toDataURL('image/png').split(',')[1]; + + var result = await apiService.remoteInpaint( + imageB64, maskB64, + params.prompt, + { + negativePrompt: params.negative_prompt || '', + steps: params.steps || 30, + cfgScale: (params.cfg_scale || 75) / 10, + } + ); + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = config.layer.width_original; + resultCanvas.height = config.layer.height_original; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('ai_replace_selection', 'AI Replace Selection', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + + alertify.dismissAll(); + alertify.success('Done!'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Replace failed: ' + (err.message || err)); + this.isProcessing = false; + } + } + + async _buildMaskCanvas() { + var w = config.layer.width_original; + var h = config.layer.height_original; + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + var ctx = canvas.getContext('2d'); + + // Prefer smartSelectMask (all selection tools write here) + if (window.smartSelectMask?.canvas) { + ctx.drawImage(window.smartSelectMask.canvas, 0, 0, w, h); + // Ensure pure B&W + var d = ctx.getImageData(0, 0, w, h); + for (var i = 0; i < d.data.length; i += 4) { + var v = d.data[i] > 128 ? 255 : 0; + d.data[i] = d.data[i+1] = d.data[i+2] = v; + d.data[i+3] = 255; + } + ctx.putImageData(d, 0, 0); + return canvas; + } + + // Fall back to rectangular selection + var sel = this._getRectSelection(); + if (sel) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = '#fff'; + ctx.fillRect(sel.x, sel.y, sel.width, sel.height); + return canvas; + } + + return null; + } +} + +export default Ai_replace_selection_class; diff --git a/paintplus/frontend/src/js/tools/ai_smart_inpaint.js b/paintplus/frontend/src/js/tools/ai_smart_inpaint.js new file mode 100644 index 0000000..61f6322 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ai_smart_inpaint.js @@ -0,0 +1,201 @@ +/** + * AI Smart Inpaint — paint a mask, enter a prompt, choose Fast (LaMa) or Quality (remote). + * + * Fast mode: /api/erase — LaMa local, no API key, seconds + * Quality mode: /api/inpaint/remote — InvokeAI / ComfyUI / OpenAI, requires configured provider + * + * Registered as tool name: "ai_smart_inpaint" + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; +import { getCapabilities } from './../api/capabilities.js'; + +class Ai_smart_inpaint_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.POP = new Dialog_class(); + this.ctx = ctx; + this.name = 'ai_smart_inpaint'; + + this.isDrawing = false; + this.isProcessing = false; + this.maskCanvas = null; + this.maskCtx = null; + } + + load() { + var _this = this; + document.addEventListener('mousedown', function (e) { _this.mousedown(e); }); + document.addEventListener('mousemove', function (e) { _this.mousemove(e); }); + document.addEventListener('mouseup', function (e) { _this.mouseup(e); }); + document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false }); + document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false }); + document.addEventListener('touchend', function (e) { _this.mouseup(e); }); + } + + on_activate() { + // Nothing on activate — tool is drag-to-paint, then dialog on mouseup + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (!mouse.click_valid) return; + if (config.TOOL.name !== this.name) return; + if (this.isProcessing) return; + if (config.layer.type !== 'image') { + alertify.error('This layer must contain an image.'); + return; + } + this._initMask(); + this.isDrawing = true; + this._paint(mouse); + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name !== this.name) return; + this._paint(this.get_mouse_info(e)); + } + + mouseup(e) { + if (!this.isDrawing) return; + this.isDrawing = false; + if (config.TOOL.name !== this.name) return; + + var maskData = this.maskCtx.getImageData( + 0, 0, this.maskCanvas.width, this.maskCanvas.height + ); + if (!maskData.data.some((v, i) => i % 4 === 3 && v > 0)) return; + + this._showDialog(); + } + + // ── Private ────────────────────────────────────────────────────────────── + + _initMask() { + var w = config.layer.width_original; + var h = config.layer.height_original; + if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) { + this.maskCanvas = document.createElement('canvas'); + this.maskCanvas.width = w; + this.maskCanvas.height = h; + this.maskCtx = this.maskCanvas.getContext('2d'); + } + this.maskCtx.clearRect(0, 0, w, h); + } + + _paint(mouse) { + var params = this.getParams(); + var size = params.size || 30; + var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width')); + var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height')); + this.maskCtx.beginPath(); + this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2); + this.maskCtx.fillStyle = '#ffffff'; + this.maskCtx.fill(); + } + + async _showDialog() { + var caps = await getCapabilities(); + var hasRemote = caps.remote && caps.remote.healthy; + + var _this = this; + + var settings = { + title: 'AI Smart Inpaint', + params: [ + { + name: 'quality', + title: 'Mode:', + value: 'fast', + values: hasRemote ? ['fast', 'quality'] : ['fast'], + note: hasRemote ? 'Fast = LaMa (local). Quality = remote AI + prompt.' : 'Quality mode requires a remote provider (InvokeAI / ComfyUI / OpenAI).', + }, + { + name: 'prompt', + title: 'What to put here (Quality mode only):', + type: 'textarea', + value: '', + placeholder: "e.g. 'lush green grass', 'wooden table surface', 'clear blue sky'", + }, + { + name: 'negative_prompt', + title: 'Avoid (optional):', + value: '', + placeholder: 'blurry, distorted', + }, + ], + on_load: function (params, popup) {}, + on_finish: function (params) { + _this._runInpaint(params.quality, params.prompt, params.negative_prompt); + }, + }; + + this.POP.show(settings); + } + + async _runInpaint(quality, prompt, negativePrompt) { + if (this.isProcessing) return; + this.isProcessing = true; + + var modeLabel = quality === 'quality' ? 'Quality (remote)' : 'Fast (LaMa)'; + alertify.message('Inpainting (' + modeLabel + ')... please wait', 0); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = config.layer.width_original; + layerCanvas.height = config.layer.height_original; + layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0); + var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1]; + var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1]; + + var result; + if (quality === 'quality') { + result = await apiService.remoteInpaint(imageB64, maskB64, prompt || 'fill naturally', { negativePrompt }); + } else { + result = await apiService.erase(imageB64, maskB64); + } + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = config.layer.width_original; + resultCanvas.height = config.layer.height_original; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('ai_smart_inpaint', 'AI Smart Inpaint', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + + alertify.dismissAll(); + alertify.success('Done!'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Inpaint failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Ai_smart_inpaint_class; diff --git a/paintplus/frontend/src/js/tools/animation.js b/paintplus/frontend/src/js/tools/animation.js new file mode 100644 index 0000000..de7d588 --- /dev/null +++ b/paintplus/frontend/src/js/tools/animation.js @@ -0,0 +1,116 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import GUI_tools_class from './../core/gui/gui-tools.js'; +import Base_gui_class from './../core/base-gui.js'; +import Base_selection_class from './../core/base-selection.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Animation_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.GUI_tools = new GUI_tools_class(); + this.Base_gui = new Base_gui_class(); + this.name = 'animation'; + this.intervalID = null; + this.index = 0; + this.toggle_layer_visibility_action = new app.Actions.Toggle_layer_visibility_action(); + + this.disable_selection(ctx); + } + + load() { + //nothing + } + + render(ctx, layer) { + //nothing + } + + /** + * disable_selection + */ + disable_selection(ctx) { + var sel_config = { + enable_background: false, + enable_borders: false, + enable_controls: false, + enable_rotation: false, + enable_move: false, + data_function: function () { + return null; + }, + }; + this.Base_selection = new Base_selection_class(ctx, sel_config, this.name); + } + + on_params_update(data) { + if(data.key != "play") + return; + + var params = this.getParams(); + if (config.layers.length == 1) { + alertify.error('Can not animate 1 layer.'); + return; + } + this.stop(); + + if (params.play == true) { + this.start(params.delay); + } + } + + on_activate() { + return [ + new app.Actions.Stop_animation_action(false) + ]; + } + + on_leave() { + return [ + new app.Actions.Stop_animation_action(true) + ]; + } + + start(delay) { + var _this = this; + delay = parseInt(delay); + if (delay < 0) + delay = 50; + + this.intervalID = window.setInterval(function () { + _this.play(_this); + }, delay); + } + + stop() { + new app.Actions.Stop_animation_action(true).do(); + } + + play(_this) { + + for (var i in config.layers) { + config.layers[i].visible = false; + } + + //show 1 + if (config.layers[this.index] != undefined) { + this.toggle_layer_visibility_action.layer_id = config.layers[this.index].id; + this.toggle_layer_visibility_action.do(); + } + + //change index + if (config.layers[this.index + 1] != undefined) { + this.index++; + } + else { + this.index = 0; + } + } + +} +; +export default Animation_class; diff --git a/paintplus/frontend/src/js/tools/blur.js b/paintplus/frontend/src/js/tools/blur.js new file mode 100644 index 0000000..ab17bfb --- /dev/null +++ b/paintplus/frontend/src/js/tools/blur.js @@ -0,0 +1,140 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import ImageFilters from './../libs/imagefilters.js'; +import Helper_class from './../libs/helpers.js'; + +class Blur_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'blur'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + } + + load() { + this.default_events(); + } + + default_dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + + //mouse cursor + var mouse = this.get_mouse_info(event); + var params = this.getParams(); + this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + return; + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + //do blur + this.blur_general('click', mouse, params.size, params.strength); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + if (this.started == false) { + return; + } + + //do blur + this.blur_general('move', mouse, params.size, params.strength); + + //draw draft preview + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('blur_tool', 'Blur Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + blur_general(type, mouse, size, strength) { + var ctx = this.tmpCanvasCtx; + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + var size_w = this.adaptSize(size, 'width'); + var size_h = this.adaptSize(size, 'height'); + + //find center + var center_x = mouse_x - Math.round(size_w / 2); + var center_y = mouse_y - Math.round(size_h / 2); + + //convert float coords to integers + center_x = Math.round(center_x); + center_y = Math.round(center_y); + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + if (type == 'move') { + strength = strength / 2; + if (strength < 1) + strength = 1; + } + + var imageData = ctx.getImageData(center_x, center_y, size_w, size_h); + var filtered = ImageFilters.StackBlur(imageData, strength); //add effect + this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered); + } + +} +export default Blur_class; diff --git a/paintplus/frontend/src/js/tools/brush.js b/paintplus/frontend/src/js/tools/brush.js new file mode 100644 index 0000000..f27ba8a --- /dev/null +++ b/paintplus/frontend/src/js/tools/brush.js @@ -0,0 +1,570 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; + +class Brush_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.name = 'brush'; + this.layer = {}; + this.params_hash = false; + this.pressure_supported = false; + this.pointer_pressure = 0; // has range [0 - 1] + this.max_speed = 20; + this.power = 2; //how speed affects size + this.event_links = []; + this.data_index = 0; + } + + load() { + var _this = this; + var is_touch = false; + + //pointer events + document.addEventListener('pointerdown', function (event) { + _this.pointerdown(event); + }); + document.addEventListener('pointermove', function (event) { + _this.pointermove(event); + }); + + //mouse events + document.addEventListener('mousedown', function (event) { + if(is_touch) + return; + _this.dragStart(event); + }); + document.addEventListener('mousemove', function (event) { + if(is_touch) + return; + _this.dragMove(event); + }); + document.addEventListener('mouseup', function (event) { + if(is_touch) + return; + _this.dragEnd(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + is_touch = true; + _this.dragStart(event); + }); + document.addEventListener('touchmove', function (event) { + _this.dragMove(event); + }); + document.addEventListener('touchend', function (event) { + _this.dragEnd(event); + }); + } + + pointerdown(e) { + // Devices that don't actually support pen pressure can give 0.5 as a false reading. + // It is highly unlikely a real pen will read exactly 0.5 at the start of a stroke. + if (e.pressure && e.pressure !== 0 && e.pressure !== 0.5 && e.pressure <= 1) { + this.pressure_supported = true; + this.pointer_pressure = e.pressure; + } else { + this.pressure_supported = false; + } + } + + pointermove(e) { + // Pressure of exactly 1 seems to be an input error, sometimes I see it when lifting the pen + // off the screen when pressure reading should be near 0. + if (this.pressure_supported && e.pressure < 1) { + this.pointer_pressure = e.pressure; + } + } + + dragStart(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + this.click_counter++; + + var mouse = this.get_mouse_info(event); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var events = []; + if (event.changedTouches) { + events = event.changedTouches; + } + else{ + events.push(event); + } + for(var i = 0; i < events.length; i++){ + var identifier = null; + if(typeof events[i].identifier != "undefined") { + identifier = events[i].identifier; + } + + this.event_links.push({ + identifier: identifier, + index: this.data_index, + }); + + _this.mousedown_action(events[i], this.data_index, identifier); + + this.data_index++; + } + } + + dragMove(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + + if (typeof event.changedTouches == "undefined") { + //mouse cursor + var mouse = _this.get_mouse_info(event); + var params = _this.getParams(); + _this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + } + + var mouse = this.get_mouse_info(event); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var events = []; + if (event.changedTouches) { + events = event.changedTouches; + } + else{ + events.push(event); + } + for(var i = 0; i < events.length; i++){ + var identifier = null; + if(typeof events[i].identifier != "undefined") { + identifier = events[i].identifier; + } + + for(var j = 0; i < this.event_links.length; j++){ + if(this.event_links[j].identifier == identifier){ + //found link + _this.mousemove_action(events[i], this.event_links[j].index); + break; + } + } + } + } + + dragEnd(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + + var mouse = this.get_mouse_info(event); + if (mouse.click_valid == false) { + return; + } + + var events = []; + if (event.changedTouches) { + events = event.changedTouches; + } + else{ + events.push(event); + } + for(var i = 0; i < events.length; i++){ + var identifier = null; + if(typeof events[i].identifier != "undefined") { + //unlink + identifier = events[i].identifier; + } + + for(var j = 0; i < this.event_links.length; j++){ + if(this.event_links[j].identifier == identifier){ + this.event_links.splice(j, 1); + break; + } + } + + _this.mouseup_action(events[i]); + } + } + + mousedown_action(e, index, event_identifier) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + var params_hash = this.get_params_hash(); + + if (config.layer.type != this.name || params_hash != this.params_hash) { + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + data: [[]], + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: 0, + y: 0, + width: config.WIDTH, + height: config.HEIGHT, + hide_selection_if_active: true, + rotate: null, + is_vector: true, + color: config.COLOR + }; + app.State.do_action( + new app.Actions.Bundle_action('new_brush_layer', 'New Brush Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + this.params_hash = params_hash; + + //reset event links index + this.data_index = 0; + index = 0; + this.event_links = []; + this.event_links.push({ + identifier: event_identifier, + index: this.data_index, + }); + } + else { + const new_data = JSON.parse(JSON.stringify(config.layer.data)); + new_data.push([]); + app.State.do_action( + new app.Actions.Bundle_action('update_brush_layer', 'Update Brush Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + data: new_data + }) + ]) + ); + } + + //in case of undo, recalculate index + for(var i = index; i >= 0; i++){ + if(typeof config.layer.data[index] != "undefined"){ + break; + } + index--; + } + + var current_group = config.layer.data[index]; + var params = this.getParams(); + + //detect line size + var size = params.size; + var new_size = size; + + if (params.pressure == true) { + if (this.pressure_supported) { + new_size = size * this.pointer_pressure * 2; + } + else { + new_size = size + size / this.max_speed * mouse.speed_average * this.power; + new_size = Math.max(new_size, size / 4); + new_size = Math.round(new_size); + } + } + + var mouse_coords = this.get_mouse_coordinates_from_event(e); + var mouse_x = mouse_coords.x; + var mouse_y = mouse_coords.y; + + current_group.push([mouse_x - config.layer.x, mouse_y - config.layer.y, new_size]); + this.Base_layers.render(); + } + + mousemove_action(e, index) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + //in case of undo, recalculate index + for(var i = index; i >= 0; i++){ + if(typeof config.layer.data[index] != "undefined"){ + break; + } + index--; + } + + var params = this.getParams(); + var current_group = config.layer.data[index]; + + //detect line size + var size = params.size; + var new_size = size; + + if (params.pressure == true) { + if (this.pressure_supported) { + new_size = size * this.pointer_pressure * 2; + } + else { + new_size = size + size / this.max_speed * mouse.speed_average * this.power; + new_size = Math.max(new_size, size / 4); + new_size = Math.round(new_size); + } + } + + var mouse_coords = this.get_mouse_coordinates_from_event(e); + var mouse_x = mouse_coords.x; + var mouse_y = mouse_coords.y; + + current_group.push([mouse_x - config.layer.x, mouse_y - config.layer.y, new_size]); + config.layer.status = 'draft'; + this.Base_layers.render(); + } + + mouseup_action(e, index) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + config.layer.status = null; + + this.check_dimensions(); + this.Base_layers.render(); + } + + render(ctx, layer) { + if (layer.data.length == 0) + return; + + var params = layer.params; + var size = params.size; + + //set styles + ctx.save(); + ctx.fillStyle = layer.color; + ctx.strokeStyle = layer.color; + ctx.lineWidth = params.size; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + ctx.translate(layer.x, layer.y); + + var data = layer.data; + + //check for legacy format + data = this.check_legacy_format(data); + + var n = data.length; + for (var k = 0; k < n; k++) { + var group_data = data[k]; //data from mouse down till mouse release + var group_n = group_data.length; + + if (params.pressure == false) { + //stabilized lines method does not support multiple line sizes + this.render_stabilized(ctx, group_data); + } + else { + if (group_data[0]) { + ctx.beginPath(); + ctx.moveTo(group_data[0][0], group_data[0][1]); + for (var i = 1; i < group_n; i++) { + if (group_data[i] === null) { + //break + ctx.beginPath(); + } + else { + //line + + ctx.lineWidth = group_data[i][2]; + + if (group_data[i - 1] == null && group_data[i + 1] == null) { + //exception - point + ctx.arc(group_data[i][0], group_data[i][1], size / 2, 0, 2 * Math.PI, false); + ctx.fill(); + } + else if (group_data[i - 1] != null) { + //lines + ctx.lineWidth = group_data[i][2]; + ctx.beginPath(); + ctx.moveTo(group_data[i - 1][0], group_data[i - 1][1]); + ctx.lineTo(group_data[i][0], group_data[i][1]); + ctx.stroke(); + } + } + } + if (group_data[1] == null) { + //point + ctx.beginPath(); + ctx.arc(group_data[0][0], group_data[0][1], size / 2, 0, 2 * Math.PI, false); + ctx.fill(); + } + } + } + } + + ctx.translate(-layer.x, -layer.y); + ctx.restore(); + } + + /** + * draw stabilized lines + * author: Manoj Verma + * source: https://stackoverflow.com/questions/7891740/drawing-smooth-lines-with-canvas/44810470#44810470 + * + * @param ctx + * @param queue + */ + render_stabilized(ctx, queue) { + var data = JSON.parse(JSON.stringify(queue)); + var n = data.length; + + if (data.length == 1) { + //point + var point = data[0]; + ctx.beginPath(); + ctx.arc(point[0], point[1], point[2] / 2, 0, 2 * Math.PI, false); + ctx.fill(); + return; + } + else if (data.length <= 5) { + //not enough points yet + + for (var i = 1; i < n; i++) { + ctx.beginPath(); + ctx.moveTo(data[i - 1][0], data[i - 1][1]); + ctx.lineTo(data[i][0], data[i][1]); + ctx.stroke(); + } + return; + } + + //fix for loose ending, so lets duplicate last point + data.push([data[n - 1][0], data[n - 1][1]]); + + ctx.beginPath(); + ctx.moveTo(data[0][0], data[0][1]); + + //prepare + var temp_data1 = [data[0]]; + var c, d; + for (var i = 1; i < data.length - 1; i = i+1) { + c = (data[i][0] + data[i + 1][0]) / 2; + d = (data[i][1] + data[i + 1][1]) / 2; + temp_data1.push([c, d]); + } + + var temp_data2 = [temp_data1[0]]; + for (var i = 1; i < temp_data1.length - 1; i = i+1) { + c = (temp_data1[i][0] + temp_data1[i + 1][0]) / 2; + d = (temp_data1[i][1] + temp_data1[i + 1][1]) / 2; + temp_data2.push([c, d]); + } + + var temp_data = [temp_data2[0]]; + for (var i = 1; i < temp_data2.length - 1; i = i+1) { + c = (temp_data2[i][0] + temp_data2[i + 1][0]) / 2; + d = (temp_data2[i][1] + temp_data2[i + 1][1]) / 2; + temp_data.push([c, d]); + } + + //draw + for (var i = 1; i < temp_data.length - 2; i = i+1) { + c = (temp_data[i][0] + temp_data[i + 1][0]) / 2; + d = (temp_data[i][1] + temp_data[i + 1][1]) / 2; + ctx.quadraticCurveTo(temp_data[i][0], temp_data[i][1], c, d); + } + + // For the last 2 points + ctx.quadraticCurveTo( + temp_data[i][0], + temp_data[i][1], + temp_data[i+1][0], + temp_data[i+1][1] + ); + ctx.stroke(); + } + + check_legacy_format(data) { + //check for legacy format + if(data.length > 0 && typeof data[0][0] == "number"){ + //convert + var legacy = JSON.parse(JSON.stringify(data)); + data = []; + data.push([]); + var group_index = 0; + for(var i in legacy){ + if(legacy[i] === null){ + data.push([]); + group_index++; + } + else { + data[group_index].push([legacy[i][0], legacy[i][1], legacy[i][2]]); + } + } + } + + return data; + } + + /** + * recalculate layer x, y, width and height values. + */ + check_dimensions() { + var data = JSON.parse(JSON.stringify(config.layer.data)); // Deep copy for history + this.check_legacy_format(data); + + if(config.layer.data.length == 0 || data[0].length == 0) + return; + + //find bounds + var min_x = data[0][0][0]; + var min_y = data[0][0][1]; + var max_x = data[0][0][0]; + var max_y = data[0][0][1]; + + var n = data.length; + for (var k = 0; k < n; k++) { + var group_data = data[k]; + var group_n = group_data.length; + + for (var i = 1; i < group_n; i++) { + min_x = Math.min(min_x, group_data[i][0]); + min_y = Math.min(min_y, group_data[i][1]); + max_x = Math.max(max_x, group_data[i][0]); + max_y = Math.max(max_y, group_data[i][1]); + } + } + + //move current data + for (var k = 0; k < n; k++) { + var group_data = data[k]; + var group_n = group_data.length; + + for (var i = 0; i < group_n; i++) { + group_data[i][0] = group_data[i][0] - min_x; + group_data[i][1] = group_data[i][1] - min_y; + } + } + + //change layers bounds + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x: config.layer.x + min_x, + y: config.layer.y + min_y, + width: max_x - min_x, + height: max_y - min_y, + data + }), + { + merge_with_history: ['new_brush_layer', 'update_brush_layer'] + } + ); + } + +} + +export default Brush_class; diff --git a/paintplus/frontend/src/js/tools/brush_select.js b/paintplus/frontend/src/js/tools/brush_select.js new file mode 100644 index 0000000..9302cd3 --- /dev/null +++ b/paintplus/frontend/src/js/tools/brush_select.js @@ -0,0 +1,801 @@ +/** + * Brush Select Tool - Canva-style brush-over-to-select with AI (SAM) + * Paint over objects to select them - AI detects the actual boundaries + * Much more intuitive than click-to-select + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; +import { SelectionActions, updateLayerWithResult } from './selection_actions.js'; + +class Brush_select_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'brush_select'; + + // Brush state + this.isDrawing = false; + this.brushPoints = []; // Points collected during brush stroke + this.brushPath = []; // Visual path for rendering + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + + // Processing state + this.isProcessing = false; + + // Quick-action panel shown after selection + this.selectionActions = new SelectionActions(this); + } + + load() { + var _this = this; + + // Mouse events + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + document.addEventListener('mousemove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.mouseup(e); + }); + + // Touch events + document.addEventListener('touchstart', function (e) { + _this.mousedown(e); + }); + document.addEventListener('touchmove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('touchend', function (e) { + _this.mouseup(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + // Delete - delete selected area + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + // Escape - clear selection + if (code == 27) { + e.preventDefault(); + _this.clearSelection(); + } + // Ctrl+C - copy to new layer + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + // Ctrl+X - cut to new layer + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + // Start marching ants animation + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask || _this.isDrawing) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + if (this.isProcessing) { + alertify.warning('Processing... please wait'); + return; + } + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + this.isDrawing = true; + this.brushPoints = []; + this.brushPath = []; + + // Get first point + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + this.brushPoints.push(point); + this.brushPath.push({ x: mouse.x, y: mouse.y }); + } + + config.need_render = true; + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + var mouse = this.get_mouse_info(e); + + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + // Sample points at intervals (not every pixel) + var lastPoint = this.brushPoints[this.brushPoints.length - 1]; + var dist = Math.sqrt(Math.pow(point.x - lastPoint.x, 2) + Math.pow(point.y - lastPoint.y, 2)); + + // Collect points every ~20 pixels for SAM + if (dist >= 20) { + this.brushPoints.push(point); + } + + // Always update visual path + this.brushPath.push({ x: mouse.x, y: mouse.y }); + } + + config.need_render = true; + } + + async mouseup(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + this.isDrawing = false; + + if (this.brushPoints.length < 2) { + // Too few points - treat as single click + if (this.brushPoints.length === 1) { + await this.selectWithSinglePoint(this.brushPoints[0]); + } + this.brushPath = []; + config.need_render = true; + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + // Process the brush stroke with SAM + await this.processBrushSelection(isAdditive); + + this.brushPath = []; + config.need_render = true; + } + + getImagePoint(mouseX, mouseY) { + var x = mouseX - config.layer.x; + var y = mouseY - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + // Clamp to image bounds + x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x))); + y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y))); + + return { x: x, y: y }; + } + + /** + * Process brush stroke - send multiple points to SAM and combine masks + */ + async processBrushSelection(isAdditive) { + this.isProcessing = true; + alertify.message('AI is analyzing your selection...'); + + try { + // Get image data as base64 + var imageData = this.getLayerImageData(); + + // Sample key points from brush stroke (up to 10 points for efficiency) + var samplePoints = this.sampleKeyPoints(this.brushPoints, 10); + + // Get mask for each point and combine + var combinedMask = null; + + for (var i = 0; i < samplePoints.length; i++) { + var point = samplePoints[i]; + + try { + var result = await apiService.smartSelect(imageData, point.x, point.y); + + // Decode mask + var maskCanvas = await this.decodeMask(result.mask); + + if (combinedMask === null) { + combinedMask = maskCanvas; + } else { + // Combine masks (union) + var ctx = combinedMask.getContext('2d'); + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(maskCanvas, 0, 0); + } + } catch (err) { + console.warn(`Point ${i} failed:`, err); + } + } + + if (combinedMask === null) { + throw new Error('No valid masks returned'); + } + + // Apply the combined mask + this.applyMaskCanvas(combinedMask, isAdditive); + + if (isAdditive && this.currentMask) { + alertify.success('Added to selection! Shift+brush to add more.'); + } else { + // Offer to float the selection for immediate manipulation (Canva-like) + this.offerFloatSelection(); + } + + } catch (error) { + console.error('Brush select error:', error); + alertify.error('Selection failed: ' + error.message); + } finally { + this.isProcessing = false; + } + } + + /** + * Select with a single point (click behavior) + */ + async selectWithSinglePoint(point) { + this.isProcessing = true; + alertify.message('AI is analyzing...'); + + try { + var imageData = this.getLayerImageData(); + var result = await apiService.smartSelect(imageData, point.x, point.y); + + var maskCanvas = await this.decodeMask(result.mask); + this.applyMaskCanvas(maskCanvas, false); + + // Offer to float the selection for immediate manipulation + this.offerFloatSelection(); + + } catch (error) { + console.error('Select error:', error); + alertify.error('Selection failed: ' + error.message); + } finally { + this.isProcessing = false; + } + } + + /** + * Show quick-action panel after selection (AI operations, scale, clipboard paste, etc.) + */ + offerFloatSelection() { + var imageData = this.getLayerImageData(); + var maskData = this.maskCanvas + ? this.maskCanvas.toDataURL('image/png').split(',')[1] + : null; + if (maskData) { + this.selectionActions.show(imageData, maskData); + } + } + + /** + * Update the current layer canvas with a base64 result from a backend operation. + */ + updateLayerWithResult(base64) { + updateLayerWithResult(base64, this); + } + + /** + * Sample key points from brush stroke for SAM + */ + sampleKeyPoints(points, maxPoints) { + if (points.length <= maxPoints) { + return points; + } + + var sampled = []; + var step = (points.length - 1) / (maxPoints - 1); + + for (var i = 0; i < maxPoints; i++) { + var idx = Math.round(i * step); + sampled.push(points[idx]); + } + + return sampled; + } + + /** + * Decode base64 mask to canvas + */ + decodeMask(maskBase64) { + return new Promise((resolve, reject) => { + var img = new Image(); + img.onload = function() { + var canvas = document.createElement('canvas'); + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + var ctx = canvas.getContext('2d'); + // Scale the mask image to match the layer dimensions + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + resolve(canvas); + }; + img.onerror = function(e) { + console.error('Failed to decode mask image:', e); + reject(e); + }; + img.src = 'data:image/png;base64,' + maskBase64; + }); + } + + /** + * Apply a mask canvas as the current selection + */ + applyMaskCanvas(maskCanvas, isAdditive) { + // If additive and we have an existing mask, combine them + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = config.layer.width_original; + combinedCanvas.height = config.layer.height_original; + var combinedCtx = combinedCanvas.getContext('2d'); + + // Draw existing mask + combinedCtx.drawImage(this.maskCanvas, 0, 0); + + // Add new mask + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(maskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = maskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + // Store globally for AI inpaint and other tools + window.smartSelectMask = this.currentMask; + + // Calculate bounds and extract contour + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + } + + /** + * Get the current layer's image data as base64 + */ + getLayerImageData() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + ctx.drawImage(config.layer.link, 0, 0); + + return canvas.toDataURL('image/png').split(',')[1]; + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + // Draw current brush stroke + if (this.isDrawing && this.brushPath.length > 1) { + ctx.save(); + + // Draw brush stroke preview + ctx.strokeStyle = 'rgba(0, 200, 255, 0.8)'; + ctx.lineWidth = 20 / config.ZOOM; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + ctx.beginPath(); + ctx.moveTo(this.brushPath[0].x, this.brushPath[0].y); + for (var i = 1; i < this.brushPath.length; i++) { + ctx.lineTo(this.brushPath[i].x, this.brushPath[i].y); + } + ctx.stroke(); + + // Draw dots at sample points + ctx.fillStyle = 'rgba(255, 255, 0, 0.9)'; + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + for (var j = 0; j < this.brushPoints.length; j++) { + var pt = this.brushPoints[j]; + ctx.beginPath(); + ctx.arc( + config.layer.x + pt.x * scaleX, + config.layer.y + pt.y * scaleY, + 5 / config.ZOOM, 0, Math.PI * 2 + ); + ctx.fill(); + } + + ctx.restore(); + } + + // Draw existing selection + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay on non-selected areas + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ccff' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var maskedCanvas = document.createElement('canvas'); + maskedCanvas.width = layer.width_original; + maskedCanvas.height = layer.height_original; + var maskedCtx = maskedCanvas.getContext('2d'); + + maskedCtx.drawImage(layer.link, 0, 0); + maskedCtx.globalCompositeOperation = 'destination-in'; + maskedCtx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX + 1; + var cropHeight = bounds.origMaxY - bounds.origMinY + 1; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + maskedCanvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: Math.round(cropWidth * scaleX), + height: Math.round(cropHeight * scaleY), + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: layer.name + ' (Brush Selection)', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + this.Base_layers.render(); + } + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var maskedCanvas = document.createElement('canvas'); + maskedCanvas.width = layer.width_original; + maskedCanvas.height = layer.height_original; + var maskedCtx = maskedCanvas.getContext('2d'); + + maskedCtx.drawImage(layer.link, 0, 0); + maskedCtx.globalCompositeOperation = 'destination-in'; + maskedCtx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX + 1; + var cropHeight = bounds.origMaxY - bounds.origMinY + 1; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + maskedCanvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: Math.round(cropWidth * scaleX), + height: Math.round(cropHeight * scaleY), + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: layer.name + ' (Cut)', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + this.Base_layers.render(); + } + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.selectionActions.hide(); + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + this.brushPoints = []; + this.brushPath = []; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.selectionActions.hide(); + this.isDrawing = false; + this.isProcessing = false; + this.brushPath = []; + return []; + } +} + +export default Brush_select_class; diff --git a/paintplus/frontend/src/js/tools/bulge_pinch.js b/paintplus/frontend/src/js/tools/bulge_pinch.js new file mode 100644 index 0000000..d7da4c4 --- /dev/null +++ b/paintplus/frontend/src/js/tools/bulge_pinch.js @@ -0,0 +1,118 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import glfx from './../libs/glfx.js'; +import Helper_class from './../libs/helpers.js'; + +class BulgePinch_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.fx_filter = false; + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'bulge_pinch'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + } + + load() { + this.default_events(); + } + + default_dragMove(event) { + if (config.TOOL.name != this.name) + return; + + //mouse cursor + var mouse = this.get_mouse_info(event); + var params = this.getParams(); + this.show_mouse_cursor(mouse.x, mouse.y, params.radius, 'circle'); + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + return; + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + //apply + this.bulgePinch_general(mouse, params.power, params.radius, params.bulge); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('bulge_pinch_tool', 'Bulge/Pinch Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + bulgePinch_general(mouse, power, radius, bulge) { + if (this.fx_filter == false) { + //init glfx lib + this.fx_filter = glfx.canvas(); + } + + var ctx = this.tmpCanvasCtx; + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + + //convert float coords to integers + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + power = power / 100; + if (power > 1) { + //max 100% + power = 1; + } + + if (bulge == false) + power = -1 * power; + + var texture = this.fx_filter.texture(this.tmpCanvas); + this.fx_filter.draw(texture).bulgePinch(mouse_x, mouse_y, radius, power).update(); //effect + this.tmpCanvasCtx.clearRect(0, 0, this.tmpCanvas.width, this.tmpCanvas.height); + this.tmpCanvasCtx.drawImage(this.fx_filter, 0, 0); + } + +} +export default BulgePinch_class; diff --git a/paintplus/frontend/src/js/tools/clone.js b/paintplus/frontend/src/js/tools/clone.js new file mode 100644 index 0000000..74694fe --- /dev/null +++ b/paintplus/frontend/src/js/tools/clone.js @@ -0,0 +1,335 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Layer_raster_class from './../modules/layer/raster.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Clone_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Layer_raster = new Layer_raster_class(); + this.ctx = ctx; + this.name = 'clone'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + this.clone_coords = null; + this.pressTimer = null; + } + + load() { + var _this = this; + var is_touch = false; + + //mouse events + document.addEventListener('mousedown', function (event) { + if(is_touch) + return; + _this.dragStart(event); + }); + document.addEventListener('mousemove', function (event) { + if(is_touch) + return; + _this.dragMove(event); + }); + document.addEventListener('mouseup', function (event) { + if(is_touch) + return; + _this.dragEnd(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + is_touch = true; + _this.dragStart(event); + }); + document.addEventListener('touchmove', function (event) { + _this.dragMove(event); + }); + document.addEventListener('touchend', function (event) { + _this.dragEnd(event); + }); + + document.addEventListener('contextmenu', function (event) { + _this.mouseRightClick(event); + }); + } + + dragStart(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousedown(event); + + var mouse = this.get_mouse_info(event); + if (mouse.click_valid == true) { + this.pressTimer = window.setTimeout(function() { + //long press success + _this.mouseLongClick(); + }, 2000); + } + } + + dragMove(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousemove(event); + + //mouse cursor + var mouse = _this.get_mouse_info(event); + var params = _this.getParams(); + _this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + + clearTimeout(this.pressTimer); + } + + dragEnd(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mouseup(event); + + clearTimeout(this.pressTimer); + } + + on_params_update() { + var params = this.getParams(); + var strict_element = document.getElementById('strict'); + + if (params.circle == false) { + //hide strict controls + strict_element.style.display = 'none'; + } + else { + //show strict controls + strict_element.style.display = 'block'; + } + } + + mouseRightClick(e) { + if (config.TOOL.name != this.name) + return; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (e.which == 3 && mouse.valid == true) { + e.preventDefault(); + } + if (params.source_layer.value == 'Previous' && config.layer.type === null) { + this.Layer_raster.raster(); + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + if (e.which == 3 && mouse.valid == true) { + //right click - save coords + + var mouse_x = this.adaptSize(mouse.x, 'width'); + var mouse_y = this.adaptSize(mouse.y, 'height'); + + this.clone_coords = { + x: mouse_x, + y: mouse_y, + }; + alertify.success('Source coordinates saved.'); + } + } + + mouseLongClick(){ + var params = this.getParams(); + var mouse = this.get_mouse_info(); + + if (params.source_layer.value == 'Previous' && config.layer.type === null) { + this.Layer_raster.raster(); + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + + var mouse_x = this.adaptSize(mouse.x, 'width'); + var mouse_y = this.adaptSize(mouse.y, 'height'); + + this.clone_coords = { + x: mouse_x, + y: mouse_y, + }; + alertify.success('Source coordinates saved.'); + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + var layer = config.layer; + var previous_layer = this.Base_layers.find_previous(config.layer.id); + + if (mouse.click_valid == false) { + return; + } + + if (params.source_layer.value == 'Previous' && config.layer.type === null) { + this.Layer_raster.raster(); + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + if (this.clone_coords === null) { + alertify.error('Source is empty, right click on image or use long press to save source position.'); + return; + } + if (layer.width != layer.width_original || layer.height != layer.height_original) { + alertify.error('Clone tool disabled for resized image. Please rasterize first.'); + return; + } + if (params.source_layer.value == 'Previous' && + (previous_layer.width != previous_layer.width_original + || previous_layer.height != previous_layer.height_original)) { + alertify.error('Clone tool disabled for resized image. Please rasterize first.'); + return; + } + if (params.source_layer.value == 'Previous') { + if (previous_layer == null) { + alertify.error('Can not find previous layer.'); + return; + } + if (previous_layer.type != 'image') { + alertify.error('Previous layer must be image, convert it to raster to apply this tool.'); + return; + } + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + //clone + this.clone_general(this.tmpCanvas, this.tmpCanvas, 'click', mouse); + + //register tmp canvas for progress redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + if (this.started == false) { + return; + } + + //clone + this.clone_general(this.tmpCanvas, this.tmpCanvas, 'move', mouse); + + //draw draft preview + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('clone_tool', 'Clone Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + clone_general(canvas_from, canvas_to, type, mouse) { + var params = this.getParams(); + + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + var half = Math.round(params.size / 2); + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + + //convert float coords to integers + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + //create source canvas + var canvas_source = document.createElement("canvas"); + var ctx_source = canvas_source.getContext("2d"); + var w = Math.ceil(params.size); + var h = Math.ceil(params.size); + canvas_source.width = w; + canvas_source.height = h; + + //add data + var x_from = Math.round(this.clone_coords.x - (mouse.click_x - mouse_x)); + var y_from = Math.round(this.clone_coords.y - (mouse.click_y - mouse_y)); + if (params.anti_aliasing == false) { + ctx_source.arc(half, half, half, 0, Math.PI * 2, false); + ctx_source.clip(); + } + if (params.source_layer.value == 'Previous') { + var previous_layer = this.Base_layers.find_previous(config.layer.id); + + x_from = Math.round(this.clone_coords.x - (mouse.click_x - mouse_x)) - previous_layer.x + config.layer.x; + y_from = Math.round(this.clone_coords.y - (mouse.click_y - mouse_y)) - previous_layer.y + config.layer.y; + + ctx_source.drawImage(previous_layer.link, x_from - half, y_from - half, w, h, 0, 0, w, h); + } + else { + ctx_source.drawImage(canvas_from, x_from - half, y_from - half, w, h, 0, 0, w, h); + } + + //apply anti aliasing + if (params.anti_aliasing == true) { + var gradient = ctx_source.createRadialGradient(half, half, 0, half, half, half + 1); + gradient.addColorStop(0, 'white'); + gradient.addColorStop(0.3, 'white'); + gradient.addColorStop(1, 'transparent'); + ctx_source.fillStyle = gradient; + + ctx_source.globalCompositeOperation = 'destination-in'; + ctx_source.fillRect(0, 0, params.size, params.size); + ctx_source.globalCompositeOperation = 'source-over'; + } + + //finish + canvas_to.getContext("2d").drawImage(canvas_source, mouse_x - half, mouse_y - half); + } + +} +export default Clone_class; diff --git a/paintplus/frontend/src/js/tools/crop.js b/paintplus/frontend/src/js/tools/crop.js new file mode 100644 index 0000000..fa53f2d --- /dev/null +++ b/paintplus/frontend/src/js/tools/crop.js @@ -0,0 +1,306 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import GUI_tools_class from './../core/gui/gui-tools.js'; +import Base_gui_class from './../core/base-gui.js'; +import Base_selection_class from './../core/base-selection.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Crop_class extends Base_tools_class { + + constructor(ctx) { + super(); + var _this = this; + this.Base_layers = new Base_layers_class(); + this.Base_gui = new Base_gui_class(); + this.GUI_tools = new GUI_tools_class(); + this.ctx = ctx; + this.name = 'crop'; + this.selection = { + x: null, + y: null, + width: null, + height: null, + }; + var sel_config = { + enable_background: true, + enable_borders: true, + enable_controls: true, + crop_lines: true, + enable_rotation: false, + enable_move: false, + data_function: function () { + return _this.selection; + }, + }; + this.mousedown_selection = null; + this.Base_selection = new Base_selection_class(ctx, sel_config, this.name); + } + + load() { + this.default_events(); + } + + default_dragStart(event) { + this.is_mousedown_canvas = false; + if (config.TOOL.name != this.name) + return; + if (!event.target.closest('#main_wrapper')) + return; + + this.is_mousedown_canvas = true; + this.mousedown(event); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (this.Base_selection.is_drag == false || mouse.click_valid == false) + return; + + this.mousedown_selection = JSON.parse(JSON.stringify(this.selection)); + + if (this.Base_selection.mouse_lock !== null) { + return; + } + + //create new selection + this.Base_selection.set_selection(mouse.x, mouse.y, 0, 0); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (this.Base_selection.is_drag == false || mouse.is_drag == false) { + return; + } + if (e.type == 'mousedown' && mouse.click_valid == false) { + return; + } + if (this.Base_selection.mouse_lock !== null) { + return; + } + + var width = mouse.x - mouse.click_x; + var height = mouse.y - mouse.click_y; + + if(e.ctrlKey == true || e.metaKey){ + //ctrl is pressed - crop will be calculated based on global width and height ratio + var ratio = config.WIDTH / config.HEIGHT; + var width_new = Math.round(height * ratio); + var height_new = Math.round(width / ratio); + + if(Math.abs(width * 100 / width_new) > Math.abs(height * 100 / height_new)){ + if (width * 100 / width_new > 0) + height = height_new; + else + height = -height_new; + } + else{ + if (height * 100 / height_new > 0) + width = width_new; + else + width = -width_new; + } + } + + this.Base_selection.set_selection(null, null, width, height); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + + if (!this.Base_selection.is_drag) { + return; + } + if (e.type == 'mousedown' && mouse.click_valid == false) { + return; + } + + var width = mouse.x - this.selection.x; + var height = mouse.y - this.selection.y; + + if (width == 0 || height == 0) { + //cancel selection + this.Base_selection.reset_selection(); + config.need_render = true; + return; + } + + if (this.selection.width != null) { + //make sure coords not negative + var details = this.selection; + var x = details.x; + var y = details.y; + if (details.width < 0) { + x = x + details.width; + } + if (details.height < 0) { + y = y + details.height; + } + this.selection = { + x: x, + y: y, + width: Math.abs(details.width), + height: Math.abs(details.height), + }; + } + + //control boundaries + if (this.selection.x < 0) { + this.selection.width += this.selection.x; + this.selection.x = 0; + } + if (this.selection.y < 0) { + this.selection.height += this.selection.y; + this.selection.y = 0; + } + if (this.selection.x + this.selection.width > config.WIDTH) { + this.selection.width = config.WIDTH - this.selection.x; + } + if (this.selection.y + this.selection.height > config.HEIGHT) { + this.selection.height = config.HEIGHT - this.selection.y; + } + + app.State.do_action( + new app.Actions.Set_selection_action(this.selection.x, this.selection.y, this.selection.width, this.selection.height, this.mousedown_selection) + ); + } + + render(ctx, layer) { + //nothing + } + + /** + * do actual crop + */ + async on_params_update() { + var params = this.getParams(); + var selection = this.selection; + params.crop = true; + this.GUI_tools.show_action_attributes(); + + if (selection.width == null || selection.width == 0 || selection.height == 0) { + alertify.error('Empty selection'); + return; + } + + //check for rotation + var rotated_name = false; + for (var i in config.layers) { + var link = config.layers[i]; + if (link.type == null) + continue; + + if(link.rotate > 0){ + rotated_name = link.name; + break; + } + } + if (rotated_name !== false) { + alertify.error('Crop on rotated layer is not supported. Convert it to raster to continue.' + '('+ rotated_name + ')'); + return; + } + + //controll boundaries + selection.x = Math.max(selection.x, 0); + selection.y = Math.max(selection.y, 0); + selection.width = Math.min(selection.width, config.WIDTH); + selection.height = Math.min(selection.height, config.HEIGHT); + + let actions = []; + + for (var i in config.layers) { + var link = config.layers[i]; + if (link.type == null) + continue; + + let x = link.x; + let y = link.y; + let width = link.width; + let height = link.height; + let width_original = link.width_original; + let height_original = link.height_original; + + //move + x -= parseInt(selection.x); + y -= parseInt(selection.y); + + if (link.type == 'image') { + //also remove unvisible data + let left = 0; + if (x < 0) + left = -x; + let top = 0; + if (y < 0) + top = -y; + let right = 0; + if (x + width > selection.width) + right = x + width - selection.width; + let bottom = 0; + if (y + height > selection.height) + bottom = y + height - selection.height; + let crop_width = width - left - right; + let crop_height = height - top - bottom; + + //if image was streched + let width_ratio = (width / width_original); + let height_ratio = (height / height_original); + + //create smaller canvas + let canvas = document.createElement('canvas'); + let ctx = canvas.getContext("2d"); + canvas.width = crop_width / width_ratio; + canvas.height = crop_height / height_ratio; + + //cut required part + ctx.translate(-left / width_ratio, -top / height_ratio); + canvas.getContext("2d").drawImage(link.link, 0, 0); + ctx.translate(0, 0); + actions.push( + new app.Actions.Update_layer_image_action(canvas, link.id) + ); + + //update attributes + width = Math.ceil(canvas.width * width_ratio); + height = Math.ceil(canvas.height * height_ratio); + x += left; + y += top; + width_original = canvas.width; + height_original = canvas.height; + } + + actions.push( + new app.Actions.Update_layer_action(link.id, { + x, + y, + width, + height, + width_original, + height_original + }) + ); + } + + actions.push( + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ + WIDTH: parseInt(selection.width), + HEIGHT: parseInt(selection.height) + }), + new app.Actions.Prepare_canvas_action('do'), + new app.Actions.Reset_selection_action(this.selection) + ); + await app.State.do_action( + new app.Actions.Bundle_action('crop_tool', 'Crop Tool', actions) + ); + } + + on_leave() { + return [ + new app.Actions.Reset_selection_action() + ]; + } + +} + +export default Crop_class; diff --git a/paintplus/frontend/src/js/tools/desaturate.js b/paintplus/frontend/src/js/tools/desaturate.js new file mode 100644 index 0000000..0d06d08 --- /dev/null +++ b/paintplus/frontend/src/js/tools/desaturate.js @@ -0,0 +1,135 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import ImageFilters from './../libs/imagefilters.js'; +import Helper_class from './../libs/helpers.js'; + +class Desaturate_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'desaturate'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + } + + load() { + this.default_events(); + } + + default_dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + + //mouse cursor + var mouse = this.get_mouse_info(event); + var params = this.getParams(); + this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + return; + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + //do desaturate + this.desaturate_general('click', mouse, params.size, params.anti_aliasing); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + if (this.started == false) { + return; + } + + //do desaturate + this.desaturate_general('move', mouse, params.size, params.anti_aliasing); + + //draw draft preview + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('desaturate_tool', 'Desaturate Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + desaturate_general(type, mouse, size, anti_aliasing) { + var ctx = this.tmpCanvasCtx; + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + var size_w = this.adaptSize(size, 'width'); + var size_h = this.adaptSize(size, 'height'); + + //find center + var center_x = mouse_x - Math.round(size_w / 2); + var center_y = mouse_y - Math.round(size_h / 2); + + //convert float coords to integers + center_x = Math.round(center_x); + center_y = Math.round(center_y); + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + var imageData = ctx.getImageData(center_x, center_y, size_w, size_h); + var filtered = ImageFilters.GrayScale(imageData); //add effect + this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered, anti_aliasing); + } + +} +export default Desaturate_class; diff --git a/paintplus/frontend/src/js/tools/ellipse_select.js b/paintplus/frontend/src/js/tools/ellipse_select.js new file mode 100644 index 0000000..724c1e1 --- /dev/null +++ b/paintplus/frontend/src/js/tools/ellipse_select.js @@ -0,0 +1,657 @@ +/** + * Ellipse Selection Tool - Draw elliptical/circular selections + * Drag to create ellipse selection, Shift+Drag for perfect circle + * Hold Alt to draw from center + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Ellipse_select_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'ellipse_select'; + + // Drawing state + this.isDrawing = false; + this.startPoint = null; + this.currentPoint = null; + this.isAdditive = false; + this.isCircle = false; + this.fromCenter = false; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse events + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + document.addEventListener('mousemove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.mouseup(e); + }); + + // Touch events + document.addEventListener('touchstart', function (e) { + _this.mousedown(e); + }); + document.addEventListener('touchmove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('touchend', function (e) { + _this.mouseup(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask || _this.isDrawing) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + this.isDrawing = true; + this.isAdditive = e.shiftKey; + this.isCircle = false; + this.fromCenter = e.altKey; + + this.startPoint = this.getImagePoint(mouse.x, mouse.y); + this.currentPoint = this.startPoint; + + config.need_render = true; + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + var mouse = this.get_mouse_info(e); + + this.currentPoint = this.getImagePoint(mouse.x, mouse.y); + this.isCircle = e.shiftKey; + this.fromCenter = e.altKey; + + config.need_render = true; + } + + mouseup(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + this.isDrawing = false; + + var mouse = this.get_mouse_info(e); + this.currentPoint = this.getImagePoint(mouse.x, mouse.y); + this.isCircle = e.shiftKey; + this.fromCenter = e.altKey; + + // Calculate ellipse bounds + var ellipse = this.calculateEllipse(); + + if (ellipse.radiusX < 2 || ellipse.radiusY < 2) { + alertify.warning('Draw a larger selection'); + config.need_render = true; + return; + } + + // Create mask from ellipse + this.createMaskFromEllipse(ellipse, this.isAdditive); + + this.startPoint = null; + this.currentPoint = null; + } + + getImagePoint(mouseX, mouseY) { + var x = mouseX - config.layer.x; + var y = mouseY - config.layer.y; + + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x))); + y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y))); + + return { x: x, y: y }; + } + + calculateEllipse() { + if (!this.startPoint || !this.currentPoint) { + return { centerX: 0, centerY: 0, radiusX: 0, radiusY: 0 }; + } + + var x1 = this.startPoint.x; + var y1 = this.startPoint.y; + var x2 = this.currentPoint.x; + var y2 = this.currentPoint.y; + + var width = Math.abs(x2 - x1); + var height = Math.abs(y2 - y1); + + // If Shift is held, make it a circle (equal radii) + if (this.isCircle) { + var maxDim = Math.max(width, height); + width = maxDim; + height = maxDim; + } + + var centerX, centerY, radiusX, radiusY; + + if (this.fromCenter) { + // Draw from center + centerX = x1; + centerY = y1; + radiusX = width; + radiusY = height; + } else { + // Draw from corner + var left = Math.min(x1, x2); + var top = Math.min(y1, y2); + + if (this.isCircle) { + // Adjust for circle from corner + if (x2 < x1) left = x1 - width; + if (y2 < y1) top = y1 - height; + } + + centerX = left + width / 2; + centerY = top + height / 2; + radiusX = width / 2; + radiusY = height / 2; + } + + return { + centerX: centerX, + centerY: centerY, + radiusX: radiusX, + radiusY: radiusY + }; + } + + createMaskFromEllipse(ellipse, isAdditive) { + var width = config.layer.width_original; + var height = config.layer.height_original; + + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + + // Draw filled ellipse + maskCtx.fillStyle = 'white'; + maskCtx.beginPath(); + maskCtx.ellipse( + ellipse.centerX, + ellipse.centerY, + ellipse.radiusX, + ellipse.radiusY, + 0, 0, Math.PI * 2 + ); + maskCtx.fill(); + + // Combine with existing mask if additive + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + combinedCtx.drawImage(this.maskCanvas, 0, 0); + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + window.smartSelectMask = this.currentMask; + + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Hold Shift while dragging to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + // Draw current ellipse being drawn + if (this.isDrawing && this.startPoint && this.currentPoint) { + ctx.save(); + + var ellipse = this.calculateEllipse(); + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + ctx.strokeStyle = '#ffff00'; + ctx.lineWidth = 2 / config.ZOOM; + ctx.setLineDash([5, 5]); + ctx.lineDashOffset = -this.marchingAntsOffset; + + ctx.beginPath(); + ctx.ellipse( + config.layer.x + ellipse.centerX * scaleX, + config.layer.y + ellipse.centerY * scaleY, + ellipse.radiusX * scaleX, + ellipse.radiusY * scaleY, + 0, 0, Math.PI * 2 + ); + ctx.stroke(); + + ctx.restore(); + } + + // Draw existing selection + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ffff00' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Ellipse Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Ellipse Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + this.startPoint = null; + this.currentPoint = null; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.isDrawing = false; + this.startPoint = null; + this.currentPoint = null; + return []; + } +} + +export default Ellipse_select_class; diff --git a/paintplus/frontend/src/js/tools/erase.js b/paintplus/frontend/src/js/tools/erase.js new file mode 100644 index 0000000..6a239e3 --- /dev/null +++ b/paintplus/frontend/src/js/tools/erase.js @@ -0,0 +1,207 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Erase_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'erase'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + } + + load() { + this.default_events(); + } + + default_dragMove(event, is_touch) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event, is_touch); + + //mouse cursor + var mouse = this.get_mouse_info(event); + var params = this.getParams(); + if (params.circle == true) + this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + else + this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'rect'); + } + + on_params_update() { + var params = this.getParams(); + var strict_element = document.querySelector('.attributes #strict'); + + if (params.circle == false) { + //hide strict controls + strict_element.style.display = 'none'; + } + else { + //show strict controls + strict_element.style.display = 'block'; + } + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + return; + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.is_vector == true) { + alertify.error('Layer is vector, convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + this.tmpCanvasCtx.scale( + config.layer.width_original / config.layer.width, + config.layer.height_original / config.layer.height + ); + + //do erase + this.erase_general(this.tmpCanvasCtx, 'click', mouse, params.size, params.strict, params.circle); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mousemove(e, is_touch) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + if (this.started == false) { + return; + } + if (mouse.click_x == mouse.x && mouse.click_y == mouse.y) { + //same coordinates + return; + } + + //do erase + this.erase_general(this.tmpCanvasCtx, 'move', mouse, params.size, params.strict, params.circle, is_touch); + + //draw draft preview + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('erase_tool', 'Erase Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + erase_general(ctx, type, mouse, size, strict, is_circle, is_touch) { + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + var alpha = config.ALPHA; + var mouse_last_x = parseInt(mouse.last_x) - config.layer.x; + var mouse_last_y = parseInt(mouse.last_y) - config.layer.y; + + ctx.beginPath(); + ctx.lineWidth = size; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + if (alpha < 255) + ctx.strokeStyle = "rgba(255, 255, 255, " + alpha / 255 / 10 + ")"; + else + ctx.strokeStyle = "rgba(255, 255, 255, 1)"; + + if (is_circle == false) { + //rectangle + var size_half = Math.ceil(size / 2); + if (size == 1) { + //single cell mode + mouse_x = Math.floor(mouse.x) - config.layer.x; + mouse_y = Math.floor(mouse.y) - config.layer.y; + size_half = 0; + } + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = "rgba(255, 255, 255, " + alpha / 255 + ")"; + ctx.fillRect(mouse_x - size_half, mouse_y - size_half, size, size); + ctx.restore(); + } + else { + //circle + ctx.save(); + + if (strict == false) { + var radgrad = ctx.createRadialGradient( + mouse_x, mouse_y, size / 8, + mouse_x, mouse_y, size / 2); + if (type == 'click') + radgrad.addColorStop(0, "rgba(255, 255, 255, " + alpha / 255 + ")"); + else if (type == 'move') + radgrad.addColorStop(0, "rgba(255, 255, 255, " + alpha / 255 / 2 + ")"); + radgrad.addColorStop(1, "rgba(255, 255, 255, 0)"); + } + + //set Composite + ctx.globalCompositeOperation = 'destination-out'; + if (strict == true) + ctx.fillStyle = "rgba(255, 255, 255, " + alpha / 255 + ")"; + else + ctx.fillStyle = radgrad; + ctx.beginPath(); + ctx.arc(mouse_x, mouse_y, size / 2, 0, Math.PI * 2, true); + ctx.fill(); + ctx.restore(); + } + + //extra work if mouse moving fast - fill gaps + if (type == 'move' && is_circle == true && mouse_last_x != false && mouse_last_y != false && is_touch !== true) { + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + + ctx.beginPath(); + ctx.moveTo(mouse_last_x, mouse_last_y); + ctx.lineTo(mouse_x, mouse_y); + ctx.stroke(); + + ctx.restore(); + } + } + +} +export default Erase_class; diff --git a/paintplus/frontend/src/js/tools/fill.js b/paintplus/frontend/src/js/tools/fill.js new file mode 100644 index 0000000..d2bdc4a --- /dev/null +++ b/paintplus/frontend/src/js/tools/fill.js @@ -0,0 +1,231 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Fill_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'fill'; + this.working = false; + } + + dragStart(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousedown(event); + } + + load() { + var _this = this; + + //mouse events + document.addEventListener('mousedown', function (event) { + _this.dragStart(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + _this.dragStart(event); + }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + + this.fill(mouse); + } + + async fill(mouse) { + var params = this.getParams(); + + if(this.working == true){ + return; + } + + if (config.layer.type != 'image' && config.layer.type !== null) { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.is_vector == true) { + alertify.error('Layer is vector, convert it to raster to apply this tool.'); + return; + } + if (config.ALPHA == 0) { + alertify.error('Color alpha value can not be zero.'); + return; + } + + //get canvas from layer + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + if (config.layer.type !== null) { + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + ctx.drawImage(config.layer.link, 0, 0); + } + else { + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + } + + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + + //convert float coords to integers + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + var color_to = this.Helper.hexToRgb(config.COLOR); + color_to.a = config.ALPHA; + + //change + this.working = true; + this.fill_general(ctx, config.WIDTH, config.HEIGHT, + mouse_x, mouse_y, color_to, params.power, params.anti_aliasing, params.contiguous); + + if (config.layer.type != null) { + //update + app.State.do_action( + new app.Actions.Bundle_action('fill_tool', 'Fill Tool', [ + new app.Actions.Update_layer_image_action(canvas) + ]) + ); + } + else { + //create new + var params = []; + params.type = 'image'; + params.name = 'Fill'; + params.data = canvas.toDataURL("image/png"); + params.x = parseInt(canvas.dataset.x) || 0; + params.y = parseInt(canvas.dataset.y) || 0; + params.width = canvas.width; + params.height = canvas.height; + app.State.do_action( + new app.Actions.Bundle_action('fill_tool', 'Fill Tool', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + } + + //prevent crash bug on touch screen - hard to explain and debug + await new Promise(r => setTimeout(r, 10)); + this.working = false; + } + + fill_general(context, W, H, x, y, color_to, sensitivity, anti_aliasing, contiguous = false) { + sensitivity = sensitivity * 255 / 100; //convert to 0-255 interval + x = parseInt(x); + y = parseInt(y); + var canvasTemp = document.createElement('canvas'); + canvasTemp.width = W; + canvasTemp.height = H; + var ctxTemp = canvasTemp.getContext("2d"); + + ctxTemp.rect(0, 0, W, H); + ctxTemp.fillStyle = "rgba(255, 255, 255, 0)"; + ctxTemp.fill(); + + var img_tmp = ctxTemp.getImageData(0, 0, W, H); + var imgData_tmp = img_tmp.data; + + var img = context.getImageData(0, 0, W, H); + var imgData = img.data; + var k = ((y * (img.width * 4)) + (x * 4)); + var dx = [0, -1, +1, 0]; + var dy = [-1, 0, 0, +1]; + var color_from = { + r: imgData[k + 0], + g: imgData[k + 1], + b: imgData[k + 2], + a: imgData[k + 3] + }; + if (color_from.r == color_to.r && color_from.g == color_to.g + && color_from.b == color_to.b && color_from.a == color_to.a) { + return false; + } + + if (contiguous == false) { + //check only nearest pixels + var stack = []; + stack.push([x, y]); + while (stack.length > 0) { + var curPoint = stack.pop(); + for (var i = 0; i < 4; i++) { + var nextPointX = curPoint[0] + dx[i]; + var nextPointY = curPoint[1] + dy[i]; + if (nextPointX < 0 || nextPointY < 0 || nextPointX >= W || nextPointY >= H) + continue; + var k = (nextPointY * W + nextPointX) * 4; + if (imgData_tmp[k + 3] != 0) + continue; //already parsed + + //check + if (Math.abs(imgData[k + 0] - color_from.r) <= sensitivity && + Math.abs(imgData[k + 1] - color_from.g) <= sensitivity && + Math.abs(imgData[k + 2] - color_from.b) <= sensitivity && + Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) { + + //fill pixel + imgData_tmp[k] = color_to.r; //r + imgData_tmp[k + 1] = color_to.g; //g + imgData_tmp[k + 2] = color_to.b; //b + imgData_tmp[k + 3] = color_to.a; //a + + stack.push([nextPointX, nextPointY]); + } + } + } + } + else { + //global mode - contiguous + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + + //imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + + for (var j = 0; j < 4; j++) { + var k = i + j; + + if (Math.abs(imgData[k] - color_from.r) <= sensitivity + && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity + && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity + && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) { + imgData_tmp[k] = color_to.r; //r + imgData_tmp[k + 1] = color_to.g; //g + imgData_tmp[k + 2] = color_to.b; //b + imgData_tmp[k + 3] = color_to.a; //a + } + } + } + } + + ctxTemp.putImageData(img_tmp, 0, 0); + if (anti_aliasing == true) { + context.filter = 'blur(1px)'; + } + context.drawImage(canvasTemp, 0, 0); + } + +} +export default Fill_class; diff --git a/paintplus/frontend/src/js/tools/gradient.js b/paintplus/frontend/src/js/tools/gradient.js new file mode 100644 index 0000000..0a1008c --- /dev/null +++ b/paintplus/frontend/src/js/tools/gradient.js @@ -0,0 +1,184 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; + +class Gradient_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'gradient'; + this.layer = {}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) + return; + + var name = this.name; + var is_vector = false; + if (params.radial == true) { + name = 'Radial gradient'; + is_vector = true; + } + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + name: this.Helper.ucfirst(name) + ' #' + this.Base_layers.auto_increment, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: mouse.x, + y: mouse.y, + rotate: null, + is_vector: is_vector, + color: null, + data: { + center_x: mouse.x, + center_y: mouse.y, + }, + }; + app.State.do_action( + new app.Actions.Bundle_action('new_gradient_layer', 'New Gradient Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var width = mouse.x - this.layer.x; + var height = mouse.y - this.layer.y; + + if (params.radial == true) { + config.layer.x = this.layer.data.center_x - width; + config.layer.y = this.layer.data.center_y - height; + config.layer.width = width * 2; + config.layer.height = height * 2; + } + else { + config.layer.width = width; + config.layer.height = height; + } + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var width = mouse.x - this.layer.x; + var height = mouse.y - this.layer.y; + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + let new_settings = {}; + if (params.radial == true) { + new_settings = { + x: this.layer.data.center_x - width, + y: this.layer.data.center_y - height, + width: width * 2, + height: height * 2 + } + } + else { + new_settings = { + width, + height + } + } + new_settings.status = null; + + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, new_settings), + { merge_with_history: 'new_gradient_layer' } + ); + + this.Base_layers.render(); + } + + render(ctx, layer) { + if (layer.width == 0 && layer.height == 0) + return; + + var params = layer.params; + var power = params.radial_power; + if(power > 99){ + power = 99; + } + var alpha = params.alpha / 100 * 255; + if(power > 255){ + power = 255; + } + + var color1 = params.color_1; + var color2 = params.color_2; + var radial = params.radial; + + var color2_rgb = this.Helper.hexToRgb(color2); + + var width = layer.x + layer.width - 1; + var height = layer.y + layer.height - 1; + + if (radial == false) { + //linear + ctx.beginPath(); + ctx.rect(0, 0, config.WIDTH, config.HEIGHT); + var grd = ctx.createLinearGradient( + layer.x, layer.y, + width, height); + + grd.addColorStop(0, color1); + grd.addColorStop(1, "rgba(" + color2_rgb.r + ", " + color2_rgb.g + ", " + + color2_rgb.b + ", " + alpha / 255 + ")"); + ctx.fillStyle = grd; + ctx.fill(); + } + else { + //radial + var dist_x = layer.width; + var dist_y = layer.height; + var center_x = layer.x + Math.round(layer.width / 2); + var center_y = layer.y + Math.round(layer.height / 2); + var distance = Math.sqrt((dist_x * dist_x) + (dist_y * dist_y)); + var radgrad = ctx.createRadialGradient( + center_x, center_y, distance * power / 100, + center_x, center_y, distance); + + radgrad.addColorStop(0, color1); + radgrad.addColorStop(1, "rgba(" + color2_rgb.r + ", " + color2_rgb.g + ", " + + color2_rgb.b + ", " + alpha / 255 + ")"); + ctx.fillStyle = radgrad; + ctx.fillRect(0, 0, config.WIDTH, config.HEIGHT); + } + } + +} +export default Gradient_class; diff --git a/paintplus/frontend/src/js/tools/lasso.js b/paintplus/frontend/src/js/tools/lasso.js new file mode 100644 index 0000000..3676e6a --- /dev/null +++ b/paintplus/frontend/src/js/tools/lasso.js @@ -0,0 +1,597 @@ +/** + * Lasso Selection Tool - Freehand selection by drawing + * Draw around an area to select it, Shift+Draw to add to selection + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Lasso_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'lasso'; + + // Drawing state + this.isDrawing = false; + this.points = []; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse events + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + document.addEventListener('mousemove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.mouseup(e); + }); + + // Touch events + document.addEventListener('touchstart', function (e) { + _this.mousedown(e); + }); + document.addEventListener('touchmove', function (e) { + _this.mousemove(e); + }); + document.addEventListener('touchend', function (e) { + _this.mouseup(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask || _this.isDrawing) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + this.isDrawing = true; + this.points = []; + + // Get point relative to image + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + this.points.push(point); + } + + config.need_render = true; + } + + mousemove(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + var mouse = this.get_mouse_info(e); + + var point = this.getImagePoint(mouse.x, mouse.y); + if (point) { + this.points.push(point); + } + + config.need_render = true; + } + + mouseup(e) { + if (!this.isDrawing) return; + if (config.TOOL.name != this.name) return; + + this.isDrawing = false; + + if (this.points.length < 3) { + alertify.warning('Draw a larger selection'); + this.points = []; + config.need_render = true; + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + // Create mask from points + this.createMaskFromPoints(isAdditive); + + this.points = []; + } + + getImagePoint(mouseX, mouseY) { + var x = mouseX - config.layer.x; + var y = mouseY - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + // Clamp to image bounds + x = Math.max(0, Math.min(config.layer.width_original - 1, Math.round(x))); + y = Math.max(0, Math.min(config.layer.height_original - 1, Math.round(y))); + + return { x: x, y: y }; + } + + createMaskFromPoints(isAdditive) { + var width = config.layer.width_original; + var height = config.layer.height_original; + + // Create new mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + + // Draw filled polygon + maskCtx.fillStyle = 'white'; + maskCtx.beginPath(); + maskCtx.moveTo(this.points[0].x, this.points[0].y); + for (var i = 1; i < this.points.length; i++) { + maskCtx.lineTo(this.points[i].x, this.points[i].y); + } + maskCtx.closePath(); + maskCtx.fill(); + + // If additive and we have an existing mask, combine them + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + combinedCtx.drawImage(this.maskCanvas, 0, 0); + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + window.smartSelectMask = this.currentMask; + + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Shift+Draw to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + // Draw current drawing path + if (this.isDrawing && this.points.length > 1) { + ctx.save(); + + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + ctx.strokeStyle = '#00ffff'; + ctx.lineWidth = 2 / config.ZOOM; + ctx.setLineDash([5, 5]); + ctx.lineDashOffset = -this.marchingAntsOffset; + + ctx.beginPath(); + ctx.moveTo( + config.layer.x + this.points[0].x * scaleX, + config.layer.y + this.points[0].y * scaleY + ); + for (var i = 1; i < this.points.length; i++) { + ctx.lineTo( + config.layer.x + this.points[i].x * scaleX, + config.layer.y + this.points[i].y * scaleY + ); + } + ctx.stroke(); + + ctx.restore(); + } + + // Draw existing selection + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ffff' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Lasso Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Lasso Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + this.points = []; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.points = []; + this.isDrawing = false; + return []; + } +} + +export default Lasso_class; diff --git a/paintplus/frontend/src/js/tools/magic_erase.js b/paintplus/frontend/src/js/tools/magic_erase.js new file mode 100644 index 0000000..6a1c891 --- /dev/null +++ b/paintplus/frontend/src/js/tools/magic_erase.js @@ -0,0 +1,214 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Magic_erase_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'magic_erase'; + this.working = false; + } + + dragStart(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousedown(event); + } + + load() { + var _this = this; + + //mouse events + document.addEventListener('mousedown', function (event) { + _this.dragStart(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + _this.dragStart(event); + }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + + this.magic_erase(mouse); + } + + async magic_erase(mouse) { + var params = this.getParams(); + + if(this.working == true){ + return; + } + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.is_vector == true) { + alertify.error('Layer is vector, convert it to raster to apply this tool.'); + return; + } + + //get canvas from layer + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext("2d"); + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + ctx.drawImage(config.layer.link, 0, 0); + + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + + //convert float coords to integers + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + + //change + this.working = true; + this.magic_erase_general(ctx, config.WIDTH, config.HEIGHT, + mouse_x, mouse_y, params.power, params.anti_aliasing, params.contiguous); + + app.State.do_action( + new app.Actions.Bundle_action('magic_erase_tool', 'Magic Eraser Tool', [ + new app.Actions.Update_layer_image_action(canvas) + ]) + ); + //prevent crash bug on touch screen - hard to explain and debug + await new Promise(r => setTimeout(r, 10)); + this.working = false; + } + + /** + * apply magic erase + * + * @param {ctx} context + * @param {int} W + * @param {int} H + * @param {int} x + * @param {int} y + * @param {int} sensitivity max 100 + * @param {Boolean} anti_aliasing + */ + magic_erase_general(context, W, H, x, y, sensitivity, anti_aliasing, contiguous = false) { + sensitivity = sensitivity * 255 / 100; //convert to 0-255 interval + x = parseInt(x); + y = parseInt(y); + var canvasTemp = document.createElement('canvas'); + canvasTemp.width = W; + canvasTemp.height = H; + var ctxTemp = canvasTemp.getContext("2d"); + + ctxTemp.rect(0, 0, W, H); + ctxTemp.fillStyle = "rgba(255, 255, 255, 0)"; + ctxTemp.fill(); + + var img_tmp = ctxTemp.getImageData(0, 0, W, H); + var imgData_tmp = img_tmp.data; + + var img = context.getImageData(0, 0, W, H); + var imgData = img.data; + var k = ((y * (img.width * 4)) + (x * 4)); + var dx = [0, -1, +1, 0]; + var dy = [-1, 0, 0, +1]; + var color_to = { + r: 255, + g: 255, + b: 255, + a: 255 + }; + var color_from = { + r: imgData[k + 0], + g: imgData[k + 1], + b: imgData[k + 2], + a: imgData[k + 3] + }; + if (color_from.r == color_to.r && + color_from.g == color_to.g && + color_from.b == color_to.b && + color_from.a == 0) { + return false; + } + if (contiguous == false) { + //check only nearest pixels + var stack = []; + stack.push([x, y]); + while (stack.length > 0) { + var curPoint = stack.pop(); + for (var i = 0; i < 4; i++) { + var nextPointX = curPoint[0] + dx[i]; + var nextPointY = curPoint[1] + dy[i]; + if (nextPointX < 0 || nextPointY < 0 || nextPointX >= W || nextPointY >= H) + continue; + var k = (nextPointY * W + nextPointX) * 4; + if (imgData_tmp[k + 3] != 0) + continue; //already parsed + + if (Math.abs(imgData[k] - color_from.r) <= sensitivity + && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity + && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity + && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) { + //erase + imgData_tmp[k] = color_to.r; //r + imgData_tmp[k + 1] = color_to.g; //g + imgData_tmp[k + 2] = color_to.b; //b + imgData_tmp[k + 3] = color_to.a; //a + + stack.push([nextPointX, nextPointY]); + } + } + } + } + else { + //global mode - contiguous + for (var i = 0; i < imgData.length; i += 4) { + if (imgData[i + 3] == 0) + continue; //transparent + + //imgData[i] + 0.7152 * imgData[i + 1] + 0.0722 * imgData[i + 2]); + + for (var j = 0; j < 4; j++) { + var k = i + j; + + if (Math.abs(imgData[k] - color_from.r) <= sensitivity + && Math.abs(imgData[k + 1] - color_from.g) <= sensitivity + && Math.abs(imgData[k + 2] - color_from.b) <= sensitivity + && Math.abs(imgData[k + 3] - color_from.a) <= sensitivity) { + imgData_tmp[k] = color_to.r; //r + imgData_tmp[k + 1] = color_to.g; //g + imgData_tmp[k + 2] = color_to.b; //b + imgData_tmp[k + 3] = color_to.a; //a + } + } + } + } + + //destination-out + blur = anti-aliasing + ctxTemp.putImageData(img_tmp, 0, 0); + context.globalCompositeOperation = "destination-out"; + if (anti_aliasing == true) { + context.filter = 'blur(1px)'; + } + context.drawImage(canvasTemp, 0, 0); + } + +} +export default Magic_erase_class; diff --git a/paintplus/frontend/src/js/tools/magic_wand.js b/paintplus/frontend/src/js/tools/magic_wand.js new file mode 100644 index 0000000..24021bb --- /dev/null +++ b/paintplus/frontend/src/js/tools/magic_wand.js @@ -0,0 +1,600 @@ +/** + * Magic Wand Selection Tool - Selects areas by color similarity + * Click to select similar colors, Shift+Click to add to selection + * Like GIMP's magic wand / fuzzy select tool + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Magic_wand_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'magic_wand'; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + } + + load() { + var _this = this; + + // Mouse click event for selection + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + // Delete - delete selected area + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + // Escape - clear selection + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + // Ctrl+C - copy to new layer + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + // Ctrl+X - cut to new layer + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + // Start marching ants animation + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + setInterval(function() { + if (_this.currentMask) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + // Get click coordinates relative to the image + var x = mouse.x - config.layer.x; + var y = mouse.y - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + x = Math.round(x); + y = Math.round(y); + + // Make sure click is within image bounds + if (x < 0 || y < 0 || x >= config.layer.width_original || y >= config.layer.height_original) { + alertify.error('Click inside the image'); + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + // Get tool parameters + var params = this.getParams(); + var tolerance = params.tolerance || 30; + var contiguous = params.contiguous !== false; + + // Perform the selection + this.selectByColor(x, y, tolerance, contiguous, isAdditive); + } + + /** + * Select pixels by color similarity using flood fill algorithm + */ + selectByColor(startX, startY, tolerance, contiguous, isAdditive) { + var layer = config.layer; + + // Get the layer's image data + var srcCanvas = document.createElement('canvas'); + srcCanvas.width = layer.width_original; + srcCanvas.height = layer.height_original; + var srcCtx = srcCanvas.getContext('2d'); + srcCtx.drawImage(layer.link, 0, 0); + + var imageData = srcCtx.getImageData(0, 0, srcCanvas.width, srcCanvas.height); + var data = imageData.data; + var width = srcCanvas.width; + var height = srcCanvas.height; + + // Create mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = width; + newMaskCanvas.height = height; + var maskCtx = newMaskCanvas.getContext('2d'); + var maskImageData = maskCtx.createImageData(width, height); + var maskData = maskImageData.data; + + // Get the color at the clicked point + var startIdx = (startY * width + startX) * 4; + var targetColor = { + r: data[startIdx], + g: data[startIdx + 1], + b: data[startIdx + 2], + a: data[startIdx + 3] + }; + + // Convert tolerance to 0-255 range + var sens = tolerance * 255 / 100; + + if (contiguous) { + // Flood fill - only connected pixels + var visited = new Uint8Array(width * height); + var stack = [[startX, startY]]; + var dx = [0, -1, +1, 0]; + var dy = [-1, 0, 0, +1]; + + while (stack.length > 0) { + var point = stack.pop(); + var px = point[0]; + var py = point[1]; + + if (px < 0 || py < 0 || px >= width || py >= height) continue; + + var idx = py * width + px; + if (visited[idx]) continue; + visited[idx] = 1; + + var i = idx * 4; + + // Check color similarity + if (Math.abs(data[i] - targetColor.r) <= sens && + Math.abs(data[i + 1] - targetColor.g) <= sens && + Math.abs(data[i + 2] - targetColor.b) <= sens && + Math.abs(data[i + 3] - targetColor.a) <= sens) { + + // Add to mask (white = selected) + maskData[i] = 255; + maskData[i + 1] = 255; + maskData[i + 2] = 255; + maskData[i + 3] = 255; + + // Add neighbors to stack + for (var d = 0; d < 4; d++) { + stack.push([px + dx[d], py + dy[d]]); + } + } + } + } else { + // Global - all matching pixels regardless of connection + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + + if (Math.abs(data[i] - targetColor.r) <= sens && + Math.abs(data[i + 1] - targetColor.g) <= sens && + Math.abs(data[i + 2] - targetColor.b) <= sens && + Math.abs(data[i + 3] - targetColor.a) <= sens) { + + maskData[i] = 255; + maskData[i + 1] = 255; + maskData[i + 2] = 255; + maskData[i + 3] = 255; + } + } + } + } + + maskCtx.putImageData(maskImageData, 0, 0); + + // If additive and we have an existing mask, combine them + if (isAdditive && this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = width; + combinedCanvas.height = height; + var combinedCtx = combinedCanvas.getContext('2d'); + + // Draw existing mask + combinedCtx.drawImage(this.maskCanvas, 0, 0); + + // Add new mask + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + this.maskCanvas = combinedCanvas; + } else { + this.maskCanvas = newMaskCanvas; + } + + this.currentMask = { + canvas: this.maskCanvas + }; + + // Store globally for other tools + window.smartSelectMask = this.currentMask; + + // Calculate bounds and extract edge + this.calculateSelectionBounds(); + this.extractContourPath(); + + config.need_render = true; + this.Base_layers.render(); + + if (isAdditive) { + alertify.success('Added to selection!'); + } else { + alertify.success('Selection complete! Shift+Click to add more.'); + } + } + + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + var isEdge = false; + + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + render_overlay(ctx) { + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay on non-selected areas + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants + if (this.edgeCanvas) { + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + antsCtx.drawImage(this.edgeCanvas, 0, 0); + antsCtx.globalCompositeOperation = 'source-in'; + + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#ff00ff' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + copyToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Magic Wand Selection', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + alertify.success('Selection copied to new layer!'); + } + + cutToLayer() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + var canvas = document.createElement('canvas'); + canvas.width = layer.width_original; + canvas.height = layer.height_original; + var ctx = canvas.getContext('2d'); + + ctx.drawImage(layer.link, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.drawImage(this.maskCanvas, 0, 0); + + var cropWidth = bounds.origMaxX - bounds.origMinX; + var cropHeight = bounds.origMaxY - bounds.origMinY; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + canvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: cropWidth, + height: cropHeight, + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: 'Magic Wand Cut', + data: croppedCanvas.toDataURL('image/png') + }; + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + this.clearSelection(); + alertify.success('Selection cut to new layer!'); + } + + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + holeCtx.drawImage(layer.link, 0, 0); + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + this.clearSelection(); + alertify.success('Selection deleted!'); + } + + clearSelection() { + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + return []; + } +} + +export default Magic_wand_class; diff --git a/paintplus/frontend/src/js/tools/media.js b/paintplus/frontend/src/js/tools/media.js new file mode 100644 index 0000000..797c09e --- /dev/null +++ b/paintplus/frontend/src/js/tools/media.js @@ -0,0 +1,164 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import File_open_class from './../modules/file/open.js'; +import Tools_settings_class from './../modules/tools/settings.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Media_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.File_open = new File_open_class(); + this.Tools_settings = new Tools_settings_class(); + this.POP = new Dialog_class(); + this.name = 'media'; + this.cache = []; + this.page = 1; + this.per_page = 50; + } + + load() { + //nothing + } + + render(ctx, layer) { + //nothing + } + + on_activate() { + this.search(); + } + + /** + * Image search api + * + * @param {string} query + * @param {array} data + * @param pages + */ + search(query = '', data = [], pages = null) { + var _this = this; + var html = ''; + var html_paging = ''; + + var key = config.pixabay_key; + key = key.split("").reverse().join(""); + + var safe_search = this.Tools_settings.get_setting('safe_search'); + + if (data.length > 0) { + for (var i in data) { + html += '
    '; + html += ' '; + html += '
    '; + } + //fix for last line + html += '
    '; + html += '
    '; + html += '
    '; + html += '
    '; + + //paging + html_paging += '
    '; + html_paging += ' '; + for(var i = 1; i <= Math.min(10, pages); i++) { + var selected = ''; + if(this.page == i){ + var selected = 'selected'; + } + html_paging += ' '; + } + html_paging += ' '; + html_paging += '
    '; + } + else{ + this.page = 1; + } + + var settings = { + title: 'Search', + //comment: 'Source: pixabay.com.', + className: 'wide', + params: [ + {name: "query", title: "Keyword:", value: query}, + ], + on_load: function (params, popup) { + var node = document.createElement("div"); + node.classList.add('flex-container'); + node.innerHTML = html + html_paging; + popup.el.querySelector('.dialog_content').appendChild(node); + //events + var targets = popup.el.querySelectorAll('.item img'); + for (var i = 0; i < targets.length; i++) { + targets[i].addEventListener('click', function (event) { + //we have click + var data = { + url: this.dataset.url, + }; + _this.File_open.file_open_url_handler(data); + _this.POP.hide(); + + new app.Actions.Activate_tool_action('select', true).do(); + }); + } + var targets = popup.el.querySelectorAll('#media_paging button'); + for (var i = 0; i < targets.length; i++) { + targets[i].addEventListener('click', function (event) { + //we have click + _this.page = parseInt(this.dataset.value); + _this.POP.save(); + }); + } + }, + on_finish: function (params) { + if (params.query == '') + return; + + var URL = "https://pixabay.com/api/?key=" + key + + "&page=" + _this.page + + "&per_page=" + _this.per_page + + "&safesearch=" + safe_search + + "&q=" + encodeURIComponent(params.query); + + if (_this.cache[URL] != undefined) { + //using cache + + setTimeout(function () { + //only call same function after all handlers finishes + var data = _this.cache[URL]; + + if (parseInt(data.totalHits) == 0) { + alertify.error('Your search did not match any images.'); + } + + var pages = Math.ceil(data.totalHits / _this.per_page); + _this.search(params.query, data.hits, pages); + }, 100); + } + else { + //query to service + $.getJSON(URL, function (data) { + _this.cache[URL] = data; + + if (parseInt(data.totalHits) == 0) { + alertify.error('Your search did not match any images.'); + } + + var pages = Math.ceil(data.totalHits / _this.per_page); + _this.search(params.query, data.hits, pages); + }) + .fail(function () { + alertify.error('Error connecting to service.'); + }); + } + }, + }; + this.POP.show(settings); + + document.getElementById("pop_data_query").select(); + } +} + +export default Media_class; diff --git a/paintplus/frontend/src/js/tools/pencil.js b/paintplus/frontend/src/js/tools/pencil.js new file mode 100644 index 0000000..68957c5 --- /dev/null +++ b/paintplus/frontend/src/js/tools/pencil.js @@ -0,0 +1,306 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; + +class Pencil_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.name = 'pencil'; + this.layer = {}; + this.params_hash = false; + this.pressure_supported = false; + this.pointer_pressure = 0; // has range [0 - 1] + } + + load() { + var _this = this; + + //pointer events + document.addEventListener('pointerdown', function (event) { + _this.pointerdown(event); + }); + document.addEventListener('pointermove', function (event) { + _this.pointermove(event); + }); + + this.default_events(); + } + + dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + } + + pointerdown(e) { + // Devices that don't actually support pen pressure can give 0.5 as a false reading. + // It is highly unlikely a real pen will read exactly 0.5 at the start of a stroke. + if (e.pressure && e.pressure !== 0 && e.pressure !== 0.5 && e.pressure <= 1) { + this.pressure_supported = true; + this.pointer_pressure = e.pressure; + } else { + this.pressure_supported = false; + } + } + + pointermove(e) { + // Pressure of exactly 1 seems to be an input error, sometimes I see it when lifting the pen + // off the screen when pressure reading should be near 0. + if (this.pressure_supported && e.pressure < 1) { + this.pointer_pressure = e.pressure; + } + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + var params_hash = this.get_params_hash(); + var opacity = Math.round(config.ALPHA / 255 * 100); + + if (config.layer.type != this.name || params_hash != this.params_hash) { + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + data: [], + opacity: opacity, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: 0, + y: 0, + width: config.WIDTH, + height: config.HEIGHT, + hide_selection_if_active: true, + rotate: null, + is_vector: true, + color: config.COLOR + }; + app.State.do_action( + new app.Actions.Bundle_action('new_pencil_layer', 'New Pencil Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + this.params_hash = params_hash; + } + else { + //continue adding layer data, just register break + const new_data = JSON.parse(JSON.stringify(config.layer.data)); + new_data.push(null); + app.State.do_action( + new app.Actions.Bundle_action('update_pencil_layer', 'Update Pencil Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + data: new_data + }) + ]) + ); + } + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + //detect line size + var size = params.size; + var new_size = size; + + if (params.pressure == true && this.pressure_supported) { + new_size = size * this.pointer_pressure * 2; + } + + //more data + config.layer.data.push([ + Math.ceil(mouse.x - config.layer.x), + Math.ceil(mouse.y - config.layer.y), + new_size + ]); + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + //detect line size + var size = params.size; + var new_size = size; + + if (params.pressure == true && this.pressure_supported) { + new_size = size * this.pointer_pressure * 2; + } + + //more data + config.layer.data.push([ + Math.ceil(mouse.x - config.layer.x), + Math.ceil(mouse.y - config.layer.y), + new_size + ]); + + this.check_dimensions(); + + config.layer.status = null; + this.Base_layers.render(); + } + + render(ctx, layer) { + this.render_aliased(ctx, layer); + } + + /** + * draw without antialiasing, sharp, ugly mode. + * + * @param {object} ctx + * @param {object} layer + */ + render_aliased(ctx, layer) { + if (layer.data.length == 0) + return; + + var params = layer.params; + var data = layer.data; + var n = data.length; + var size = params.size; + + //set styles + ctx.fillStyle = layer.color; + ctx.strokeStyle = layer.color; + ctx.translate(layer.x, layer.y); + + //draw + ctx.beginPath(); + ctx.moveTo(data[0][0], data[0][1]); + for (var i = 1; i < n; i++) { + if (data[i] === null) { + //break + ctx.beginPath(); + } + else { + //line + size = data[i][2]; + if(size == undefined){ + size = 1; + } + + if (data[i - 1] == null) { + //exception - point + ctx.fillRect( + data[i][0] - Math.floor(size / 2) - 1, + data[i][1] - Math.floor(size / 2) - 1, + size, + size + ); + } + else { + //lines + ctx.beginPath(); + this.draw_simple_line( + ctx, + data[i - 1][0], + data[i - 1][1], + data[i][0], + data[i][1], + size + ); + } + } + } + if (n == 1 || data[1] == null) { + //point + ctx.beginPath(); + ctx.fillRect( + data[0][0] - Math.floor(size / 2) - 1, + data[0][1] - Math.floor(size / 2) - 1, + size, + size + ); + } + + ctx.translate(-layer.x, -layer.y); + } + + /** + * draws line without aliasing + * + * @param {object} ctx + * @param {int} from_x + * @param {int} from_y + * @param {int} to_x + * @param {int} to_y + * @param {int} size + */ + draw_simple_line(ctx, from_x, from_y, to_x, to_y, size) { + var dist_x = from_x - to_x; + var dist_y = from_y - to_y; + var distance = Math.sqrt((dist_x * dist_x) + (dist_y * dist_y)); + var radiance = Math.atan2(dist_y, dist_x); + + for (var j = 0; j < distance; j++) { + var x_tmp = Math.round(to_x + Math.cos(radiance) * j) - Math.floor(size / 2) - 1; + var y_tmp = Math.round(to_y + Math.sin(radiance) * j) - Math.floor(size / 2) - 1; + + ctx.fillRect(x_tmp, y_tmp, size, size); + } + } + + /** + * recalculate layer x, y, width and height values. + */ + check_dimensions() { + if(config.layer.data.length == 0) + return; + + //find bounds + var data = JSON.parse(JSON.stringify(config.layer.data)); // Deep copy for history + var min_x = data[0][0]; + var min_y = data[0][1]; + var max_x = data[0][0]; + var max_y = data[0][1]; + for(var i in data){ + if(data[i] === null) + continue; + min_x = Math.min(min_x, data[i][0]); + min_y = Math.min(min_y, data[i][1]); + max_x = Math.max(max_x, data[i][0]); + max_y = Math.max(max_y, data[i][1]); + } + + //move current data + for(var i in data){ + if(data[i] === null) + continue; + data[i][0] = data[i][0] - min_x; + data[i][1] = data[i][1] - min_y; + } + + //change layers bounds + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x: config.layer.x + min_x, + y: config.layer.y + min_y, + width: max_x - min_x, + height: max_y - min_y, + data + }), + { + merge_with_history: ['new_pencil_layer', 'update_pencil_layer'] + } + ); + } + +} + +export default Pencil_class; diff --git a/paintplus/frontend/src/js/tools/pick_color.js b/paintplus/frontend/src/js/tools/pick_color.js new file mode 100644 index 0000000..f8c8752 --- /dev/null +++ b/paintplus/frontend/src/js/tools/pick_color.js @@ -0,0 +1,211 @@ +/** + * Pick Color (Eyedropper) — enhanced with live color tooltip. + * + * Hover: floating tooltip shows hex, RGB, HSL, and nearest Pantone match with ΔE. + * Click: sets as active color AND copies hex to clipboard. + * Drag: continuously samples color while dragging. + * + * ΔE (Delta E) is the color difference between the sampled color and the + * nearest Pantone ink. Lower is better: + * < 2 = Excellent — nearly identical in print + * 2–5 = Good — slight difference, acceptable for most print work + * 5–10 = Fair — noticeable difference; specify Pantone manually if color accuracy matters + * > 10 = Poor — this color cannot be faithfully reproduced as a single Pantone ink + */ + +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import Base_gui_class from './../core/base-gui.js'; +import { hexToRgb, rgbToHsl, rgbToLab, nearestPantone, deltaEBadge } from './../libs/color_utils.js'; + +class Pick_color_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.Base_gui = new Base_gui_class(); + this.ctx = ctx; + this.name = 'pick_color'; + this._tooltip = null; + this._lastHex = null; + } + + dragStart(event) { + if (config.TOOL.name !== this.name) return; + this.mousedown(event); + } + + dragMove(event) { + if (config.TOOL.name !== this.name) return; + this.mousemove(event); + } + + load() { + var _this = this; + + document.addEventListener('mousedown', e => _this.dragStart(e)); + document.addEventListener('mousemove', e => { + if (config.TOOL.name !== _this.name) { _this._hideTooltip(); return; } + _this.dragMove(e); + }); + document.addEventListener('mouseup', e => { + if (config.TOOL.name !== _this.name) return; + var mouse = _this.get_mouse_info(e); + if (mouse.click_valid) _this.copy_color_to_clipboard(); + }); + document.addEventListener('touchstart', e => _this.dragStart(e)); + document.addEventListener('touchmove', e => _this.dragMove(e)); + document.addEventListener('mouseleave', () => _this._hideTooltip()); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (!mouse.click_valid) return; + this.pick_color(mouse); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + // Show tooltip on hover (even without drag) + this._sampleAndTooltip(mouse, e.clientX, e.clientY); + if (!mouse.is_drag || !mouse.click_valid) return; + this.pick_color(mouse); + } + + pick_color(mouse) { + var params = this.getParams(); + var canvas, ctx; + if (!params.global) { + canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false); + ctx = canvas.getContext('2d'); + } else { + canvas = document.createElement('canvas'); + ctx = canvas.getContext('2d'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + this.Base_layers.convert_layers_to_canvas(ctx, null, false); + } + + var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data; + var hex = this.Helper.rgbToHex(c[0], c[1], c[2]); + + const def = { hex }; + if (c[3] > 0) def.a = c[3]; + this.Base_gui.GUI_colors.set_color(def); + this._lastHex = hex; + } + + copy_color_to_clipboard() { + navigator.clipboard.writeText(config.COLOR).catch(() => {}); + } + + // ── Tooltip ──────────────────────────────────────────────────────────────── + + _sampleAndTooltip(mouse, clientX, clientY) { + if (!config.layer || !mouse.click_valid) { this._hideTooltip(); return; } + + var params = this.getParams(); + var canvas, ctx; + try { + if (!params.global) { + canvas = this.Base_layers.convert_layer_to_canvas(config.layer.id, null, false); + ctx = canvas.getContext('2d'); + } else { + canvas = document.createElement('canvas'); + ctx = canvas.getContext('2d'); + canvas.width = config.WIDTH; + canvas.height = config.HEIGHT; + this.Base_layers.convert_layers_to_canvas(ctx, null, false); + } + } catch { this._hideTooltip(); return; } + + var c = ctx.getImageData(mouse.x, mouse.y, 1, 1).data; + var r = c[0], g = c[1], b = c[2], a = c[3]; + if (a === 0) { this._hideTooltip(); return; } + + var hex = this.Helper.rgbToHex(r, g, b); + this._showTooltip(hex, r, g, b, clientX, clientY); + } + + _showTooltip(hex, r, g, b, cx, cy) { + const hsl = rgbToHsl(r, g, b); + const pantone = nearestPantone(hex); + const badge = deltaEBadge(pantone.quality); + + // Perceived text color for swatch + const brightness = 0.299 * r + 0.587 * g + 0.114 * b; + const swatchText = brightness > 140 ? '#1a1a1a' : '#ffffff'; + + if (!this._tooltip) { + const t = document.createElement('div'); + t.id = 'pick_color_tooltip'; + Object.assign(t.style, { + position: 'fixed', + zIndex: '99999', + background: '#1a1a1a', + border: '1px solid #3a3a3a', + borderRadius: '10px', + padding: '10px 13px', + fontFamily: 'monospace, sans-serif', + fontSize: '12px', + color: '#ddd', + pointerEvents:'none', + boxShadow: '0 4px 16px rgba(0,0,0,0.6)', + minWidth: '210px', + lineHeight: '1.6', + }); + document.body.appendChild(t); + this._tooltip = t; + } + + const t = this._tooltip; + + t.innerHTML = ` +
    +
    +
    +
    +
    ${hex.toUpperCase()}
    +
    rgb(${r}, ${g}, ${b})
    +
    hsl(${hsl.h}°, ${hsl.s}%, ${hsl.l}%)
    +
    +
    +
    +
    +
    + ${pantone.name} +
    +
    + ΔE ${pantone.deltaE} + ● ${badge.label} +
    + ${pantone.quality === 'poor' + ? `
    + Tip: this color may shift significantly in print. +
    ` + : ''} +
    +
    Click to copy hex & set active color
    `; + + // Position tooltip near cursor, keep on screen + const tw = 230, th = 160; + let tx = cx + 16, ty = cy + 16; + if (tx + tw > window.innerWidth - 8) tx = cx - tw - 8; + if (ty + th > window.innerHeight - 8) ty = cy - th - 8; + t.style.left = tx + 'px'; + t.style.top = ty + 'px'; + t.style.display = 'block'; + } + + _hideTooltip() { + if (this._tooltip) this._tooltip.style.display = 'none'; + } +} + +export default Pick_color_class; diff --git a/paintplus/frontend/src/js/tools/select.js b/paintplus/frontend/src/js/tools/select.js new file mode 100644 index 0000000..dbcc923 --- /dev/null +++ b/paintplus/frontend/src/js/tools/select.js @@ -0,0 +1,704 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Base_selection_class from './../core/base-selection.js'; +import Helper_class from './../libs/helpers.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +class Select_tool_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'select'; + this.saved = false; + this.mousedown_dimensions = { x: null, y: null, width: null, height: null }; + this.keyboard_move_start_position = null; + this.moving = false; + this.resizing = false; + this.snap_line_info = {x: null, y: null}; + this.rotate_initial = null; + + var sel_config = { + enable_background: false, + enable_borders: true, + enable_controls: true, + keep_ratio: true, + enable_rotation: true, + enable_move: true, + data_function: function () { + return config.layer; + }, + }; + this.Base_selection = new Base_selection_class(ctx, sel_config, this.name); + } + + /** + * Called when the Select tool is activated + * If there's an AI selection, offer to float it so it can be moved + */ + on_activate() { + var _this = this; + + // Check if there's an active AI selection (Smart Select, Brush Select, etc.) + if (window.smartSelectMask && window.smartSelectMask.canvas) { + // Ask user if they want to float the selection + alertify.confirm( + 'Float Selection', + 'You have an active selection. Would you like to copy it to a new layer so you can move and scale it?', + function() { + // Yes - float the selection + _this.floatSelection(); + }, + function() { + // No - just clear the selection indicator + alertify.message('Tip: Use Ctrl+C in selection tools to copy, or Ctrl+X to cut.'); + } + ).set('labels', {ok: 'Yes, Float It', cancel: 'No, Keep Selection'}); + } + } + + /** + * Float the current selection to a new layer + * This copies the selected pixels to a new layer that can be moved/scaled + */ + floatSelection() { + var maskCanvas = window.smartSelectMask?.canvas; + if (!maskCanvas) { + alertify.error('No selection to float'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + // Get mask bounds + var maskCtx = maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height); + var minX = maskCanvas.width, minY = maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < maskCanvas.height; y++) { + for (var x = 0; x < maskCanvas.width; x++) { + var i = (y * maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (!hasSelection || maxX <= minX || maxY <= minY) { + alertify.error('Selection is empty or too small'); + return; + } + + // Create masked image + var maskedCanvas = document.createElement('canvas'); + maskedCanvas.width = layer.width_original; + maskedCanvas.height = layer.height_original; + var maskedCtx = maskedCanvas.getContext('2d'); + + maskedCtx.drawImage(layer.link, 0, 0); + maskedCtx.globalCompositeOperation = 'destination-in'; + maskedCtx.drawImage(maskCanvas, 0, 0); + + // Crop to selection bounds + var cropWidth = maxX - minX + 1; + var cropHeight = maxY - minY + 1; + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + croppedCtx.drawImage( + maskedCanvas, + minX, minY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + // Calculate position + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + var params = { + x: Math.round(layer.x + minX * scaleX), + y: Math.round(layer.y + minY * scaleY), + width: Math.round(cropWidth * scaleX), + height: Math.round(cropHeight * scaleY), + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: layer.name + ' (Floated)', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('float_selection', 'Float Selection', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + // Clear the selection + window.smartSelectMask = null; + + // Enable transparency + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + this.Base_layers.render(); + } + + alertify.success('Selection floated to new layer! You can now move and scale it.'); + } + + load() { + var _this = this; + + //mouse events + document.addEventListener('mousedown', function (e) { + _this.dragStart(e); + }); + document.addEventListener('mousemove', function (e) { + _this.dragMove(e); + }); + document.addEventListener('mouseup', function (e) { + _this.dragEnd(e); + }); + + // collect touch events + document.addEventListener('touchstart', function (e) { + _this.dragStart(e); + }); + document.addEventListener('touchmove', function (e) { + _this.dragMove(e); + }); + document.addEventListener('touchend', function (e) { + _this.dragEnd(e); + }); + + //keyboard actions + document.addEventListener('keydown', (event) => { + if (config.TOOL.name != this.name) + return; + if (this.POP.get_active_instances() > 0) { + return; + } + if (this.Helper.is_input(event.target)) + return; + var k = event.key; + + if (k == "ArrowUp") { + this.move(0, -1, event); + } + else if (k == "ArrowDown") { + this.move(0, 1, event); + } + else if (k == "ArrowRight") { + this.move(1, 0, event); + } + else if (k == "ArrowLeft") { + this.move(-1, 0, event); + } + if (k == "Delete") { + if (config.TOOL.name == this.name) { + app.State.do_action( + new app.Actions.Delete_layer_action(config.layer.id) + ); + } + } + }); + document.addEventListener('keyup', (event) => { + if (config.TOOL.name != this.name) + return; + if (this.POP.active == true) + return; + if (this.Helper.is_input(event.target)) + return; + var k = event.key; + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(k)) { + if (this.keyboard_move_start_position) { + let x = config.layer.x; + let y = config.layer.y; + config.layer.x = this.keyboard_move_start_position.x; + config.layer.y = this.keyboard_move_start_position.y; + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { x, y }) + ); + this.keyboard_move_start_position = null; + } + } + }); + } + + dragStart(event) { + var mouse = this.get_mouse_info(event); + if (config.TOOL.name != this.name) + return; + if (mouse.click_valid == false) { + return; + } + + this.mousedown(event); + } + + dragMove(event) { + var mouse = this.get_mouse_info(event); + if (config.TOOL.name != this.name) + return; + if (mouse.click_valid == false) { + return; + } + + this.mousemove(event); + } + + dragEnd(event) { + var mouse = this.get_mouse_info(event); + if (config.TOOL.name != this.name) + return; + if (mouse.click_valid == false) { + return; + } + + this.mouseup(event); + this.Base_layers.render(); + } + + async mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false || config.mouse_lock === true) { + return; + } + + this.rotate_initial = config.layer.rotate; + + if (this.Base_selection.mouse_lock != null) { + this.resizing = true; + this.Base_selection.find_settings().keep_ratio = config.layer.type === 'image'; + if (config.layer.type === 'text' && config.layer.params && config.layer.params.boundary === 'dynamic') { + config.layer.params.boundary = 'box'; + } + } + else { + this.moving = true; + await this.auto_select_object(e); + this.Base_selection.find_settings().keep_ratio = config.layer.type === 'image'; + this.saved = false; + } + + this.mousedown_dimensions = { + x: Math.round(config.layer.x), + y: Math.round(config.layer.y), + width: Math.round(config.layer.width), + height: Math.round(config.layer.height) + }; + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag == false || mouse.click_valid == false || config.mouse_lock === true) { + return; + } + if (this.resizing) { + + //also handle rotation + let rotate = this.Base_selection.current_angle + if(config.layer.rotate != rotate && rotate !== null){ + config.layer.rotate = rotate; + } + + return; + } + else if (this.moving) { + //move object + config.layer.x = Math.round(mouse.x - mouse.click_x + this.mousedown_dimensions.x); + config.layer.y = Math.round(mouse.y - mouse.click_y + this.mousedown_dimensions.y); + + //apply snap + var snap_info = this.calc_snap(e, config.layer.x, config.layer.y); + if(snap_info != null){ + if(snap_info.x != null) { + config.layer.x = snap_info.x; + } + if(snap_info.y != null) { + config.layer.y = snap_info.y; + } + } + + config.need_render = true; + } + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false || config.mouse_lock === true) { + return; + } + if (this.resizing) { + let x = config.layer.x; + let y = config.layer.y; + let width = config.layer.width; + let height = config.layer.height; + + //reset values + config.layer.x = this.mousedown_dimensions.x; + config.layer.y = this.mousedown_dimensions.y; + config.layer.width = this.mousedown_dimensions.width; + config.layer.height = this.mousedown_dimensions.height; + if ( + this.mousedown_dimensions.x !== x || this.mousedown_dimensions.y !== y || + this.mousedown_dimensions.width !== width || this.mousedown_dimensions.height !== height + ) { + app.State.do_action( + new app.Actions.Bundle_action('resize_layer', 'Resize Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + x, y, width, height + }) + ]) + ); + } + + //also handle rotation + let rotate = this.Base_selection.current_angle; + if(this.rotate_initial != rotate && rotate !== null){ + //save state + config.layer.rotate = this.rotate_initial; + app.State.do_action( + new app.Actions.Bundle_action('resize_layer', 'Resize Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + rotate + }) + ]) + ); + } + } + else if (this.moving) { + var new_x = Math.round(mouse.x - mouse.click_x + this.mousedown_dimensions.x); + var new_y = Math.round(mouse.y - mouse.click_y + this.mousedown_dimensions.y); + config.layer.x = this.mousedown_dimensions.x; + config.layer.y = this.mousedown_dimensions.y; + + if(mouse.x - mouse.click_x || mouse.y - mouse.click_y) { + var snap_info = this.calc_snap(e, new_x, new_y); + if (snap_info != null) { + if (snap_info.x != null) { + new_x = snap_info.x; + } + if (snap_info.y != null) { + new_y = snap_info.y; + } + } + } + + if (this.mousedown_dimensions.x !== new_x || this.mousedown_dimensions.y !== new_y) { + app.State.do_action( + new app.Actions.Bundle_action('move_layer', 'Move Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: new_x, + y: new_y + }) + ]) + ); + } + } + this.moving = false; + this.resizing = false; + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + var mouse = this.get_mouse_info(event); + + //maybe related tool have additional overlay render handlers? + if(config.layer.render_function != null) { + var render_class = config.layer.render_function[0]; + var render_function = 'select'; + if ( + typeof this.Base_gui.GUI_tools.tools_modules[render_class].object[ + render_function + ] != "undefined" + ) { + this.Base_gui.GUI_tools.tools_modules[render_class].object[ + render_function + ](this.ctx); + } + } + + if (mouse.is_drag == false) + return; + + this.render_overlay_parent(ctx); + } + + /** + * calculates current object snap coordinates and returns it. One of coordinates can be null. + * + * @param event + * @param pos_x + * @param pos_y + * @returns object|null + */ + calc_snap(event, pos_x, pos_y) { + var snap_position = { x: null, y: null }; + var params = this.getParams(); + + if(config.SNAP === false || event.shiftKey == true){ + this.snap_line_info = {x: null, y: null}; + return null; + } + + //settings + var sensitivity = 0.01; + var max_distance = (config.WIDTH + config.HEIGHT) / 2 * sensitivity / config.ZOOM; + + //collect snap positions + var snap_positions = this.get_snap_positions(config.layer.id); + + //find closest snap positions + var min_group = { + x: { + start: null, + center: null, + end: null, + }, + y: { + start: null, + center: null, + end: null, + }, + }; + var min_group_distance = { + x: { + start: null, + center: null, + end: null, + }, + y: { + start: null, + center: null, + end: null, + }, + }; + //x + for(var i in snap_positions.x){ + var distance = Math.abs(pos_x - snap_positions.x[i]); + if(distance < max_distance && (distance < min_group_distance.x.start || min_group_distance.x.start === null)){ + min_group_distance.x.start = distance; + min_group.x.start = snap_positions.x[i]; + } + + var distance = Math.abs(pos_x + config.layer.width/2 - snap_positions.x[i]); + if(distance < max_distance && (distance < min_group_distance.x.center || min_group_distance.x.center === null)){ + min_group_distance.x.center = distance; + min_group.x.center = snap_positions.x[i]; + } + + var distance = Math.abs(pos_x + config.layer.width - snap_positions.x[i]); + if(distance < max_distance && (distance < min_group_distance.x.end || min_group_distance.x.end === null)){ + min_group_distance.x.end = distance; + min_group.x.end = snap_positions.x[i]; + } + } + //y + for(var i in snap_positions.y){ + var distance = Math.abs(pos_y - snap_positions.y[i]); + if(distance < max_distance && (distance < min_group_distance.y.start || min_group_distance.y.start === null)){ + min_group_distance.y.start = distance; + min_group.y.start = snap_positions.y[i]; + } + + var distance = Math.abs(pos_y + config.layer.height/2 - snap_positions.y[i]); + if(distance < max_distance && (distance < min_group_distance.y.center || min_group_distance.y.center === null)){ + min_group_distance.y.center = distance; + min_group.y.center = snap_positions.y[i]; + } + + var distance = Math.abs(pos_y + config.layer.height - snap_positions.y[i]); + if(distance < max_distance && (distance < min_group_distance.y.end || min_group_distance.y.end === null)){ + min_group_distance.y.end = distance; + min_group.y.end = snap_positions.y[i]; + } + } + + //find best begin, center, end + var min_distance = { + x: null, + y: null, + }; + //x + if(min_group_distance.x.start != null) + min_distance.x = min_group_distance.x.start; + if(min_group_distance.x.center != null && (min_group_distance.x.center < min_distance.x || min_distance.x === null)) + min_distance.x = min_group_distance.x.center; + if(min_group_distance.x.end != null && (min_group_distance.x.end < min_distance.x || min_distance.x === null)) + min_distance.x = min_group_distance.x.end; + //y + if(min_group_distance.y.start != null) + min_distance.y = min_group_distance.y.start; + if(min_group_distance.y.center != null && (min_group_distance.y.center < min_distance.y || min_distance.y === null)) + min_distance.y = min_group_distance.y.center; + if(min_group_distance.y.end != null && (min_group_distance.y.end < min_distance.y || min_distance.y === null)) + min_distance.y = min_group_distance.y.end; + + //apply snap + var success = false; + //x + if(min_group.x.center != null && min_group_distance.x.center == min_distance.x) { + snap_position.x = Math.round(min_group.x.center - config.layer.width / 2); + success = true; + this.snap_line_info.x = { + start_x: min_group.x.center, + start_y: 0, + end_x: min_group.x.center, + end_y: config.HEIGHT + }; + } + else if(min_group.x.start != null && min_group_distance.x.start == min_distance.x) { + snap_position.x = Math.round(min_group.x.start); + success = true; + this.snap_line_info.x = { + start_x: min_group.x.start, + start_y: 0, + end_x: min_group.x.start, + end_y: config.HEIGHT, + }; + } + else if(min_group.x.end != null && min_group_distance.x.end == min_distance.x) { + snap_position.x = Math.round(min_group.x.end - config.layer.width); + success = true; + this.snap_line_info.x = { + start_x: min_group.x.end, + start_y: 0, + end_x: min_group.x.end, + end_y: config.HEIGHT + }; + } + else{ + this.snap_line_info.x = null; + } + //y + if(min_group.y.center != null && min_group_distance.y.center == min_distance.y) { + snap_position.y = Math.round(min_group.y.center - config.layer.height / 2); + success = true; + this.snap_line_info.y = { + start_x: 0, + start_y: min_group.y.center, + end_x: config.WIDTH, + end_y: min_group.y.center, + }; + } + else if(min_group.y.start != null && min_group_distance.y.start == min_distance.y) { + snap_position.y = Math.round(min_group.y.start); + success = true; + this.snap_line_info.y = { + start_x: 0, + start_y: min_group.y.start, + end_x: config.WIDTH, + end_y: min_group.y.start, + }; + } + else if(min_group.y.end != null && min_group_distance.y.end == min_distance.y) { + snap_position.y = Math.round(min_group.y.end - config.layer.height); + success = true; + this.snap_line_info.y = { + start_x: 0, + start_y: min_group.y.end, + end_x: config.WIDTH, + end_y: min_group.y.end, + }; + } + else{ + this.snap_line_info.y = null; + } + + if(success) { + return snap_position; + } + + return null; + } + + move(direction_x, direction_y, event) { + if (!this.keyboard_move_start_position) { + this.keyboard_move_start_position = { + x: config.layer.x, + y: config.layer.y + } + } + var power = 10; + if (event.ctrlKey == true || event.metaKey) + power = 50; + if (event.shiftKey == true) + power = 1; + + config.layer.x += direction_x * power; + config.layer.y += direction_y * power; + config.need_render = true; + } + + async auto_select_object(e) { + var params = this.getParams(); + if (params.auto_select == false) + return; + + var layers_sorted = this.Base_layers.get_sorted_layers(); + + //render main canvas + for (var i = 0; i < layers_sorted.length; i++) { + var value = layers_sorted[i]; + var canvas = this.Base_layers.convert_layer_to_canvas(value.id, null, false); + + if (this.check_hit_region(e, canvas.getContext("2d"), value) == true) { + await app.State.do_action( + new app.Actions.Select_layer_action(value.id) + ); + break; + } + } + } + + check_hit_region(e, ctx, layer) { + var mouse = this.get_mouse_info(e); + + if(layer.type == 'image' && Math.abs(layer.width * layer.height / 1000000) > 5){ + //too big to check using getImageData - use simple way + if (mouse.x > layer.x && mouse.x < layer.x + layer.width && + mouse.y > layer.y && mouse.y < layer.y + layer.height) { + //hit + return true; + } + + return false; + } + + var data = ctx.getImageData(mouse.x, mouse.y, 1, 1).data; + var blank = [0, 0, 0, 0]; + if (config.TRANSPARENCY == false) { + blank = [0, 0, 0, 0]; + } + + if (data[0] != blank[0] || data[1] != blank[1] || data[2] != blank[2] + || data[3] != blank[3]) { + //hit + return true; + } + + return false; + } + +} + +export default Select_tool_class; diff --git a/paintplus/frontend/src/js/tools/selection.js b/paintplus/frontend/src/js/tools/selection.js new file mode 100644 index 0000000..395b7c7 --- /dev/null +++ b/paintplus/frontend/src/js/tools/selection.js @@ -0,0 +1,348 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Base_selection_class from './../core/base-selection.js'; +import GUI_tools_class from './../core/gui/gui-tools.js'; +import Helper_class from './../libs/helpers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +var instance = null; + +class Selection_class extends Base_tools_class { + + constructor(ctx) { + super(); + + //singleton + if (instance) { + return instance; + } + instance = this; + + var _this = this; + + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'selection'; + this.type = null; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.selection_coords_from = null; + this.selection = { + x: null, + y: null, + width: null, + height: null, + }; + + var sel_config = { + enable_background: true, + enable_borders: true, + enable_controls: false, + enable_rotation: false, + enable_move: false, + data_function: function () { + return _this.selection; + }, + }; + this.mousedown_selection = null; + this.Base_selection = new Base_selection_class(ctx, sel_config, this.name); + this.GUI_tools = new GUI_tools_class(); + } + + load() { + var _this = this; + + //mouse events + document.addEventListener('mousedown', function (event) { + _this.dragStart(event); + }); + document.addEventListener('mousemove', function (event) { + _this.dragMove(event); + }); + document.addEventListener('mouseup', function (event) { + _this.dragEnd(event); + }); + + // collect touch events + document.addEventListener('touchstart', function (event) { + _this.dragStart(event); + }); + document.addEventListener('touchmove', function (event) { + _this.dragMove(event); + }); + document.addEventListener('touchend', function (event) { + _this.dragEnd(event); + }); + + document.addEventListener('keydown', (e) => { + var code = e.keyCode; + if (this.Helper.is_input(e.target)) + return; + + if (code == 27) { + //escape + app.State.do_action(new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave())); + } + if (code == 46) { + //delete + if (config.TOOL.name == this.name) { + this.delete_selection(); + } + } + if (code == 65 && (e.ctrlKey == true || e.metaKey)) { + //A + e.preventDefault(); + this.select_all(); + } + }, false); + } + + dragStart(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousedown(event); + } + + dragMove(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mousemove(event); + } + + dragEnd(event) { + var _this = this; + if (config.TOOL.name != _this.name) + return; + _this.mouseup(event); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + var layer = config.layer; + if (this.Base_selection.is_drag == false || mouse.click_valid == false) + return; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + this.mousedown_selection = JSON.parse(JSON.stringify(this.selection)); + + if (this.selection.width != null && this.selection.height != null + && mouse.x > this.selection.x + && mouse.x < this.selection.x + this.selection.width + && mouse.y > this.selection.y + && mouse.y < this.selection.y + this.selection.height + && layer.width == layer.width_original && layer.height == layer.height_original + ) { + //move + this.type = 'move'; + + if (this.tmpCanvas == null) { + this.init_tmp_canvas(); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + } + else { + //create new selection + this.selection = { + x: mouse.x, + y: mouse.y, + width: 0, + height: 0, + }; + this.type = 'create'; + this.selection_coords_from = {x: mouse.x, y: mouse.y}; + } + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (this.Base_selection.is_drag == false || mouse.is_drag == false) + return; + if (e.type == 'mousedown' && (mouse.click_valid == false) || config.layer.type != 'image') { + return; + } + if (this.selection_coords_from === null) { + return; + } + if (this.type == 'create') { + //create new selection + this.selection.width = mouse.x - mouse.click_x; + this.selection.height = mouse.y - mouse.click_y; + config.need_render = true; + } + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + + if (!this.Base_selection.is_drag) { + return; + } + if ((e.type == 'mousedown' && mouse.click_valid == false) || config.layer.type != 'image') { + return; + } + if (this.type === 'move') { + return; // Translate appears to not work at the moment + } + + var width = mouse.x - this.selection.x; + var height = mouse.y - this.selection.y; + + if (width == 0 || height == 0) { + //cancel selection + app.State.do_action( + new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave()) + ); + return; + } + + if (this.selection.width != null && this.selection.height != null) { + //make sure coords not negative + var details = this.selection; + var x = details.x; + var y = details.y; + if (details.width < 0) { + x = x + details.width; + this.selection_coords_from.x = x; + } + if (details.height < 0) { + y = y + details.height; + this.selection_coords_from.y = y; + } + this.selection = { + x: x, + y: y, + width: Math.abs(details.width), + height: Math.abs(details.height), + }; + app.State.do_action( + new app.Actions.Set_selection_action(this.selection.x, this.selection.y, this.selection.width, this.selection.height, this.mousedown_selection) + ); + } + } + + select_all() { + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + let actions = []; + + if (config.TOOL.name != this.name) { + actions.push( + new app.Actions.Activate_tool_action(this.name) + ); + } + actions.push( + new app.Actions.Set_selection_action(0, 0, config.WIDTH, config.HEIGHT, this.selection) + ); + app.State.do_action( + new app.Actions.Bundle_action('select_all', 'Select All', actions) + ); + } + + render(ctx, layer) { + //nothing + } + + save_translate() { + if (this.tmpCanvas == null) + return; + + delete config.layer.link_canvas; + app.State.do_action( + new app.Actions.Bundle_action('selection_tool', 'Selection Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + this.reset_tmp_canvas(); + config.need_render = true; + } + + delete_selection() { + var selection = this.selection; + var layer = config.layer; + + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + + if (selection == null) { + alertify.error('Nothing is selected.'); + return; + } + + this.init_tmp_canvas(); + + var mouse_x = selection.x - layer.x; + var mouse_y = selection.y - layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + selection.width = this.adaptSize(selection.width, 'width'); + selection.height = this.adaptSize(selection.height, 'height'); + + //do erase + this.tmpCanvasCtx.clearRect(mouse_x, mouse_y, selection.width, selection.height); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas), + new app.Actions.Reset_selection_action(this.selection) + ]) + ); + + this.reset_tmp_canvas(); + delete config.layer.link_canvas; + this.reset_tmp_canvas(); + } + + init_tmp_canvas() { + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + } + + on_leave() { + let actions = [ + new app.Actions.Reset_selection_action(this.selection) + ]; + delete config.layer.link_canvas; + this.reset_tmp_canvas(); + return actions; + } + + clear_selection() { + app.State.do_action( + new app.Actions.Bundle_action('clear_selection', 'Clear Selection', this.on_leave()) + ); + } + + reset_tmp_canvas() { + if (this.tmpCanvas == null) + return; + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + +} +; +export default Selection_class; diff --git a/paintplus/frontend/src/js/tools/selection_actions.js b/paintplus/frontend/src/js/tools/selection_actions.js new file mode 100644 index 0000000..104cc8c --- /dev/null +++ b/paintplus/frontend/src/js/tools/selection_actions.js @@ -0,0 +1,466 @@ +/** + * SelectionActions — floating quick-action panel that appears after a SAM selection. + * + * Surfaces high-value real-world workflows directly in the UI: + * • Scale by % — make object 3% (or any %) bigger/smaller, gap AI-filled + * • Make less symmetrical — AI redraws the region with organic variation + * • Replace with clipboard — paste clipboard image into the selection shape + * • Copy / Cut to layer — classic Photoshop workflow + * • AI Edit (custom prompt) — full inpaint with user text + * + * Usage: + * this.selectionActions = new SelectionActions(this); + * // after successful selection: + * this.selectionActions.show(imageBase64, maskBase64); + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import { showProgress, updateProgress, hideProgress, connectProgressSSE, disconnectProgressSSE } from './../libs/progress_overlay.js'; + +const BASE = window.API_BASE_URL || ''; + +export class SelectionActions { + constructor(tool) { + this.tool = tool; + this.Base_layers = new Base_layers_class(); + this._panel = null; + this._imageData = null; + this._maskData = null; + this._escHandler = null; + } + + show(imageBase64, maskBase64) { + this.hide(); + this._imageData = imageBase64; + this._maskData = maskBase64; + + var panel = document.createElement('div'); + panel.id = 'sel-actions-panel'; + panel.style.cssText = [ + 'position:fixed', + 'bottom:80px', + 'left:50%', + 'transform:translateX(-50%)', + 'background:#1a1a2e', + 'border:1px solid #3a3a6a', + 'border-radius:12px', + 'padding:14px 16px', + 'z-index:10000', + 'font-family:sans-serif', + 'font-size:12px', + 'color:#d0d0e0', + 'min-width:360px', + 'max-width:420px', + 'box-shadow:0 8px 32px rgba(0,0,0,0.7)', + 'display:flex', + 'flex-direction:column', + 'gap:6px', + ].join(';'); + + // ── Title row ──────────────────────────────────────────────────────── + var titleRow = document.createElement('div'); + titleRow.style.cssText = 'display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:2px'; + var titleBlock = document.createElement('div'); + var title = document.createElement('div'); + title.textContent = 'Selection ready'; + title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff;line-height:1.3'; + var subtitle = document.createElement('div'); + subtitle.textContent = 'Nothing has changed yet — choose an action below'; + subtitle.style.cssText = 'font-size:10px;color:#7777aa;margin-top:1px'; + titleBlock.appendChild(title); + titleBlock.appendChild(subtitle); + var closeX = document.createElement('button'); + closeX.textContent = '✕'; + closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 0 0 8px;line-height:1;flex-shrink:0'; + closeX.title = 'Dismiss (keeps your selection active)'; + closeX.onclick = () => this.hide(); + titleRow.appendChild(titleBlock); + titleRow.appendChild(closeX); + panel.appendChild(titleRow); + + // ── Section: AI Actions ────────────────────────────────────────────── + panel.appendChild(_sectionLabel('AI Actions')); + + // Scale by % + var scaleWrap = document.createElement('div'); + scaleWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px'; + var scaleRow = document.createElement('div'); + scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px'; + var scaleLabel = document.createElement('span'); + scaleLabel.textContent = 'Scale object by'; + scaleLabel.style.color = '#aaa'; + var scaleInput = document.createElement('input'); + scaleInput.type = 'number'; + scaleInput.value = '103'; + scaleInput.min = '1'; + scaleInput.max = '500'; + scaleInput.style.cssText = 'width:52px;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:2px 5px;font-size:12px'; + var scaleUnit = document.createElement('span'); + scaleUnit.textContent = '%'; + scaleUnit.style.color = '#888'; + var scaleHint = document.createElement('span'); + scaleHint.style.cssText = 'color:#6688aa;font-size:10px;margin-left:2px'; + scaleHint.textContent = '= 3% bigger'; + var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff'); + scaleBtn.style.marginLeft = 'auto'; + scaleInput.addEventListener('input', () => { + var v = parseFloat(scaleInput.value); + if (isNaN(v) || v === 100) scaleHint.textContent = '= no change'; + else if (v > 100) scaleHint.textContent = '= ' + (v - 100).toFixed(0) + '% bigger'; + else scaleHint.textContent = '= ' + (100 - v).toFixed(0) + '% smaller'; + }); + scaleBtn.onclick = () => { + var pct = parseFloat(scaleInput.value) || 103; + this._scaleSelection(pct); + }; + scaleRow.appendChild(scaleLabel); + scaleRow.appendChild(scaleInput); + scaleRow.appendChild(scaleUnit); + scaleRow.appendChild(scaleHint); + scaleRow.appendChild(scaleBtn); + var scaleDesc = document.createElement('div'); + scaleDesc.textContent = 'Moves the selected object, then AI fills the vacated area'; + scaleDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px'; + scaleWrap.appendChild(scaleRow); + scaleWrap.appendChild(scaleDesc); + panel.appendChild(scaleWrap); + + // Make less symmetrical + panel.appendChild(_actionCard( + 'Make less symmetrical', + '#1c1a2e', '#cc99ff', + 'AI redraws the selection with subtle, natural imperfections', + () => this._makeAsymmetric() + )); + + // Replace with clipboard + panel.appendChild(_actionCard( + 'Replace with clipboard', + '#1a2a1a', '#88dd88', + 'Scales your clipboard image to fit inside the selection shape', + () => this._pasteFromClipboard() + )); + + // Replace subject from file + panel.appendChild(_actionCard( + 'Replace subject from file', + '#1a2a2a', '#66ddcc', + 'Pick any photo — AI extracts its subject and places it here, matching background lighting', + () => this._replaceSubjectFromFile() + )); + + // Custom AI edit prompt + var aiWrap = document.createElement('div'); + aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px'; + var aiRow = document.createElement('div'); + aiRow.style.cssText = 'display:flex;align-items:center;gap:6px'; + var aiInput = document.createElement('input'); + aiInput.type = 'text'; + aiInput.placeholder = '"add a scar", "make it look aged", "blue eyes" …'; + aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px'; + var aiBtn = _btn('AI Edit', '#1a2a4a', '#8aacff'); + aiBtn.onclick = () => { + var instruction = aiInput.value.trim(); + if (!instruction) { alertify.warning('Enter an instruction first — describe what to change.'); return; } + this._aiEditRegion(instruction); + }; + aiInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') aiBtn.click(); + }); + var aiDesc = document.createElement('div'); + aiDesc.textContent = 'Inpaints the selected region according to your description'; + aiDesc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:4px'; + aiRow.appendChild(aiInput); + aiRow.appendChild(aiBtn); + aiWrap.appendChild(aiRow); + aiWrap.appendChild(aiDesc); + panel.appendChild(aiWrap); + + // ── Section: Classic Tools ──────────────────────────────────────────── + panel.appendChild(_sectionLabel('Classic Tools')); + var classicRow = document.createElement('div'); + classicRow.style.cssText = 'display:flex;gap:6px'; + var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88'); + copyBtn.style.flex = '1'; + copyBtn.title = 'Lift a copy of the selection onto a new layer (non-destructive)'; + copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); }; + var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888'); + cutBtn.style.flex = '1'; + cutBtn.title = 'Cut the selection to a new layer (erases from original)'; + cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); }; + var delBtn = _btn('Erase', '#2a1a1a', '#ff7766'); + delBtn.style.flex = '0 0 auto'; + delBtn.title = 'Delete the selected pixels (transparent / background color)'; + delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); }; + classicRow.appendChild(copyBtn); + classicRow.appendChild(cutBtn); + classicRow.appendChild(delBtn); + panel.appendChild(classicRow); + + document.body.appendChild(panel); + this._panel = panel; + + this._escHandler = (e) => { if (e.key === 'Escape') this.hide(); }; + document.addEventListener('keydown', this._escHandler); + } + + hide() { + if (this._panel) { this._panel.remove(); this._panel = null; } + if (this._escHandler) { + document.removeEventListener('keydown', this._escHandler); + this._escHandler = null; + } + } + + // ── Actions ───────────────────────────────────────────────────────────── + + async _scaleSelection(scalePct) { + if (!this._check()) return; + this.hide(); + showProgress('Scaling object and AI-filling the gap…', 30); + try { + var res = await _post('/api/image/scale-selection', { + image: this._imageData, + mask: this._maskData, + scale_pct: scalePct, + }); + this.tool.updateLayerWithResult(res.result); + this.tool.clearSelection(); + hideProgress(); + alertify.success('Scaled by ' + scalePct + '%!'); + } catch (e) { + hideProgress(); + alertify.error('Scale failed: ' + e.message); + } + } + + async _makeAsymmetric() { + if (!this._check()) return; + this.hide(); + connectProgressSSE('inpaint', window.API_BASE_URL || ''); + showProgress('AI is adding natural asymmetry…', 60); + try { + var res = await _post('/api/image/ai-edit-region', { + image: this._imageData, + mask: this._maskData, + instruction: 'natural asymmetry, slight organic variation, realistic, subtle imperfection', + negative_prompt:'perfectly symmetric, mirror image, artificial, identical halves', + steps: 30, + cfg_scale: 7.5, + }); + this.tool.updateLayerWithResult(res.result); + this.tool.clearSelection(); + disconnectProgressSSE(); + hideProgress(); + alertify.success('Made less symmetrical!'); + } catch (e) { + disconnectProgressSSE(); + hideProgress(); + alertify.error('AI edit failed: ' + e.message); + } + } + + async _aiEditRegion(instruction) { + if (!this._check()) return; + this.hide(); + connectProgressSSE('inpaint', window.API_BASE_URL || ''); + showProgress('AI is editing the region…', 60); + try { + var res = await _post('/api/image/ai-edit-region', { + image: this._imageData, + mask: this._maskData, + instruction: instruction, + steps: 30, + cfg_scale: 7.5, + }); + this.tool.updateLayerWithResult(res.result); + this.tool.clearSelection(); + disconnectProgressSSE(); + hideProgress(); + alertify.success('Done!'); + } catch (e) { + disconnectProgressSSE(); + hideProgress(); + alertify.error('AI edit failed: ' + e.message); + } + } + + async _pasteFromClipboard() { + if (!this._check()) return; + + if (!navigator.clipboard || !navigator.clipboard.read) { + alertify.error('Clipboard API not available. Use HTTPS or enable clipboard permissions.'); + return; + } + try { + var items = await navigator.clipboard.read(); + var clipBlob = null; + for (var item of items) { + for (var type of item.types) { + if (type.startsWith('image/')) { + clipBlob = await item.getType(type); + break; + } + } + if (clipBlob) break; + } + if (!clipBlob) { + alertify.error('No image in clipboard. Copy an image first (e.g., right-click → Copy image).'); + return; + } + + var clipBase64 = await _blobToBase64(clipBlob); + this.hide(); + showProgress('Pasting clipboard into selection…', 10); + + var res = await _post('/api/image/paste-into-selection', { + image: this._imageData, + mask: this._maskData, + paste_image: clipBase64, + }); + this.tool.updateLayerWithResult(res.result); + this.tool.clearSelection(); + hideProgress(); + alertify.success('Clipboard pasted into selection!'); + } catch (e) { + hideProgress(); + alertify.error('Paste failed: ' + e.message); + } + } + + async _replaceSubjectFromFile() { + if (!this._check()) return; + + // Open a file picker — no clipboard API required + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = 'image/*'; + + fileInput.onchange = async () => { + var file = fileInput.files[0]; + if (!file) return; + + var subjectBase64 = await _fileToBase64(file); + this.hide(); + showProgress('Extracting subject and compositing…', 15); + + try { + var res = await _post('/api/image/replace-subject', { + background_image: this._imageData, + subject_image: subjectBase64, + mask: this._maskData, + match_colors: true, + }); + this.tool.updateLayerWithResult(res.result); + this.tool.clearSelection(); + hideProgress(); + alertify.success('Subject replaced with background color matching!'); + } catch (e) { + hideProgress(); + alertify.error('Replace subject failed: ' + e.message); + } + }; + + fileInput.click(); + } + + _check() { + if (!this._imageData || !this._maskData) { + alertify.error('No selection data. Make a new selection first.'); + return false; + } + return true; + } +} + +// ── Shared method: patch into both smart_select and brush_select instances ─── + +/** + * Update the active layer canvas with a base64 result image from the backend. + * Call as `this.updateLayerWithResult(base64)` on any tool that extends Base_tools_class. + */ +export function updateLayerWithResult(base64, tool) { + var img = new Image(); + img.onload = function () { + var canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext('2d').drawImage(img, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('ai_transform', 'AI Transform', [ + new app.Actions.Update_layer_image_action(canvas, config.layer.id) + ]) + ); + // Trigger re-render + config.need_render = true; + }; + img.src = 'data:image/png;base64,' + base64; +} + +// ── Private helpers ────────────────────────────────────────────────────────── + +function _btn(text, bg, color) { + var b = document.createElement('button'); + b.textContent = text; + b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;white-space:nowrap'; + return b; +} + +function _actionCard(text, bg, color, description, handler) { + var wrap = document.createElement('div'); + wrap.style.cssText = 'background:' + bg + ';border-radius:7px;padding:7px 10px;cursor:pointer;border:1px solid transparent'; + wrap.addEventListener('mouseenter', () => { wrap.style.borderColor = color; }); + wrap.addEventListener('mouseleave', () => { wrap.style.borderColor = 'transparent'; }); + wrap.onclick = handler; + var label = document.createElement('div'); + label.textContent = text; + label.style.cssText = 'color:' + color + ';font-size:12px;font-weight:500;pointer-events:none'; + var desc = document.createElement('div'); + desc.textContent = description; + desc.style.cssText = 'color:#5566aa;font-size:10px;margin-top:3px;pointer-events:none'; + wrap.appendChild(label); + wrap.appendChild(desc); + return wrap; +} + +function _sectionLabel(text) { + var el = document.createElement('div'); + el.style.cssText = 'font-size:9px;font-weight:bold;letter-spacing:0.08em;color:#555577;text-transform:uppercase;margin-top:2px'; + el.textContent = text; + return el; +} + +async function _post(path, body) { + var r = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(err.detail || 'Request failed'); + } + return r.json(); +} + +function _blobToBase64(blob) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +function _fileToBase64(file) { + return new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = (e) => resolve(e.target.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} diff --git a/paintplus/frontend/src/js/tools/shape.js b/paintplus/frontend/src/js/tools/shape.js new file mode 100644 index 0000000..e48f792 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shape.js @@ -0,0 +1,272 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Dialog_class from './../libs/popup.js'; +import GUI_tools_class from './../core/gui/gui-tools.js'; +import File_my_library_class from './../modules/file/my_library.js'; + +var instance = null; + +class Shape_class extends Base_tools_class { + + constructor(ctx) { + super(); + + //singleton + if (instance) { + return instance; + } + instance = this; + + this.Base_layers = new Base_layers_class(); + this.GUI_tools = new GUI_tools_class(); + this.POP = new Dialog_class(); + this.My_library = new File_my_library_class(); + this.ctx = ctx; + this.name = 'shape'; + this.layer = {}; + this.preview_width = 150; + this.preview_height = 120; + this.activeTab = 'shapes'; // 'shapes' or 'library' + + this.set_events(); + } + + set_events() { + document.addEventListener('keydown', (event) => { + var code = event.keyCode; + if (this.Helper.is_input(event.target)) + return; + + if (code == 72) { + //H + this.show_shapes(); + } + }, false); + } + + load() { + + } + + on_activate() { + this.show_shapes(); + } + + async show_shapes(){ + var _this = this; + + // Build tabs HTML + var tabsHtml = '
    '; + tabsHtml += ''; + tabsHtml += ''; + tabsHtml += '
    '; + + // Build shapes HTML + var shapesHtml = '
    '; + var data = this.get_shapes(); + + for (var i in data) { + shapesHtml += '
    '; + shapesHtml += ' '; + shapesHtml += '
    ' + data[i].title + '
    '; + shapesHtml += '
    '; + } + for (var i = 0; i < 4; i++) { + shapesHtml += '
    '; + } + shapesHtml += '
    '; + + // Build library HTML placeholder + var libraryHtml = ''; + + var settings = { + title: 'Shapes & Library', + className: 'wide', + on_load: function (params, popup) { + // Add tabs + var tabsNode = document.createElement("div"); + tabsNode.innerHTML = tabsHtml; + popup.el.querySelector('.dialog_content').insertBefore(tabsNode, popup.el.querySelector('.dialog_content').firstChild); + + // Add shapes container + var shapesNode = document.createElement("div"); + shapesNode.classList.add('flex-container'); + shapesNode.innerHTML = shapesHtml; + popup.el.querySelector('.dialog_content').appendChild(shapesNode); + + // Add library container + var libraryNode = document.createElement("div"); + libraryNode.innerHTML = libraryHtml; + popup.el.querySelector('.dialog_content').appendChild(libraryNode); + + // Tab click events + var tabs = popup.el.querySelectorAll('.shape-tab'); + tabs.forEach(function(tab) { + tab.addEventListener('click', function() { + var targetTab = this.dataset.tab; + + // Update active tab + tabs.forEach(t => t.classList.remove('active')); + this.classList.add('active'); + + // Show/hide content + var shapesContent = popup.el.querySelector('.shapes-content'); + var libraryContent = popup.el.querySelector('.library-content'); + + if (targetTab === 'shapes') { + shapesContent.style.display = ''; + libraryContent.style.display = 'none'; + } else { + shapesContent.style.display = 'none'; + libraryContent.style.display = ''; + _this.loadLibraryContent(libraryContent); + } + }); + }); + + // Shape click events + var targets = popup.el.querySelectorAll('.item canvas'); + for (var i = 0; i < targets.length; i++) { + targets[i].addEventListener('click', function (event) { + _this.GUI_tools.activate_tool(this.dataset.key); + _this.POP.hide(); + }); + } + }, + }; + this.POP.show(settings); + + //sleep, lets wait till DOM is finished + await new Promise(r => setTimeout(r, 10)); + + //draw demo thumbs + for (var i in data) { + var function_name = 'demo'; + var canvas = document.getElementById('c_'+data[i].key); + var ctx = canvas.getContext("2d"); + + if(typeof data[i].object[function_name] == "undefined") + continue; + + data[i].object[function_name](ctx, 20, 20, this.preview_width - 40, this.preview_height - 40, null); + } + } + + /** + * Load library content into the library tab + */ + loadLibraryContent(container) { + var _this = this; + + this.My_library.getAllAssets(function(assets) { + var html = ''; + + if (assets.length === 0) { + html = '
    '; + html += '

    Your library is empty.

    '; + html += '

    Use File > My Library > Save to Library to add assets.

    '; + html += '
    '; + } else { + // Group by category + var categories = {}; + assets.forEach(function(asset) { + var cat = asset.category || 'General'; + if (!categories[cat]) categories[cat] = []; + categories[cat].push(asset); + }); + + html = '
    '; + html += '
    '; + + for (var cat in categories) { + html += '
    '; + html += '

    ' + cat + ' (' + categories[cat].length + ')

    '; + html += '
    '; + + categories[cat].forEach(function(asset) { + html += '
    '; + html += '' + asset.name + ''; + html += '
    ' + asset.name + '
    '; + html += '
    '; + html += ''; + html += ''; + html += '
    '; + html += '
    '; + }); + + html += '
    '; + } + + html += '
    '; + } + + container.innerHTML = html; + + // Add event handlers for library items + container.querySelectorAll('.insert-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + _this.My_library.insertAsset(id); + _this.POP.hide(); + }); + }); + + container.querySelectorAll('.delete-btn').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var id = parseInt(this.dataset.id); + if (confirm('Delete this asset?')) { + _this.My_library.deleteAsset(id, function() { + _this.loadLibraryContent(container); + }); + } + }); + }); + + // Double-click to insert + container.querySelectorAll('.library-item').forEach(function(item) { + item.addEventListener('dblclick', function() { + var id = parseInt(this.dataset.id); + _this.My_library.insertAsset(id); + _this.POP.hide(); + }); + }); + }); + } + + render(ctx, layer) { + + } + + get_shapes(){ + var list = []; + + for (var i in this.Base_gui.GUI_tools.tools_modules) { + var object = this.Base_gui.GUI_tools.tools_modules[i]; + if (object.full_key.indexOf("shapes/") == -1 ) + continue; + + list.push(object); + } + + list.sort(function(a, b) { + var nameA = a.title.toUpperCase(); + var nameB = b.title.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }); + + return list; + } + +} + +export default Shape_class; diff --git a/paintplus/frontend/src/js/tools/shapes/arrow.js b/paintplus/frontend/src/js/tools/shapes/arrow.js new file mode 100644 index 0000000..7167627 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/arrow.js @@ -0,0 +1,212 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Arrow_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'arrow'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.mouse_click = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + this.mouse_click.x = mouse_x; + this.mouse_click.y = mouse_y; + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: Math.round(mouse_x), + y: Math.round(mouse_y), + rotate: null, + is_vector: true, + color: config.COLOR + }; + app.State.do_action( + new app.Actions.Bundle_action('new_line_layer', 'New Line Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + var width = mouse_x - this.layer.x; + var height = mouse_y - this.layer.y; + if (e.ctrlKey == true || e.metaKey) { + //one direction only + if (Math.abs(width) < Math.abs(height)) + width = 0; + else + height = 0; + } + + //more data + config.layer.width = width; + config.layer.height = height; + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + var width = mouse_x - this.layer.x; + var height = mouse_y - this.layer.y; + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + if (Math.abs(width) < Math.abs(height)) + width = 0; + else + height = 0; + } + + //more data + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + width, + height, + status: null + }), + { merge_with_history: 'new_line_layer' } + ); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + this.arrow(ctx, x, y, x + width, y + height, 15); + } + + render(ctx, layer) { + if (layer.width == 0 && layer.height == 0) + return; + + var params = layer.params; + + //set styles + ctx.fillStyle = layer.color; + ctx.strokeStyle = layer.color; + ctx.lineWidth = params.size; + ctx.lineCap = 'round'; + + var width = layer.x + layer.width; + var height = layer.y + layer.height; + + var headlen = params.size * 7; + if (headlen < 15) + headlen = 15; + this.arrow(ctx, + layer.x, layer.y, + width, height, + headlen); + } + + arrow(ctx, fromx, fromy, tox, toy, headlen) { + var dx = tox - fromx; + var dy = toy - fromy; + var angle = Math.atan2(dy, dx); + ctx.beginPath(); + ctx.moveTo(fromx, fromy); + ctx.lineTo(tox, toy); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(tox - headlen * Math.cos(angle - Math.PI / 6), toy - headlen * Math.sin(angle - Math.PI / 6)); + ctx.lineTo(tox, toy); + ctx.lineTo(tox - headlen * Math.cos(angle + Math.PI / 6), toy - headlen * Math.sin(angle + Math.PI / 6)); + ctx.stroke(); + } + +} + +export default Arrow_class; diff --git a/paintplus/frontend/src/js/tools/shapes/bezier_curve.js b/paintplus/frontend/src/js/tools/shapes/bezier_curve.js new file mode 100644 index 0000000..a518073 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/bezier_curve.js @@ -0,0 +1,482 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; + +class Bezier_Curve_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'bezier_curve'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.params_hash = false; + this.selected_obj_positions = {}; + this.mouse_lock = null; + this.selected_object_drag_type = null; + this.old_data = null; + + this.events(); + } + + load() { + var _this = this; + this.default_events(); + document.addEventListener('keydown', function (event) { + if (config.TOOL.name != _this.name) { + return; + } + var code = event.code; + if (code == "Escape") { + //escape + } + }); + } + + /** + * events for handling helping lines only + */ + events() { + document.addEventListener('mousedown', (e) => { + this.selected_object_actions(e); + }); + document.addEventListener('mousemove', (e) => { + this.selected_object_actions(e); + }); + document.addEventListener('mouseup', (e) => { + this.selected_object_actions(e); + }); + + // touch + document.addEventListener('touchstart', (event) => { + this.selected_object_actions(event); + }); + document.addEventListener('touchmove', (event) => { + this.selected_object_actions(event); + }, {passive: false}); + document.addEventListener('touchend', (event) => { + this.selected_object_actions(event); + }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + + var params_hash = this.get_params_hash(); + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + const data_clone = JSON.parse(JSON.stringify(config.layer.data)); + + if (config.layer.type != this.name || params_hash != this.params_hash + || (data_clone != null && data_clone.cp2.x !== null)) { + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + data: { + start: {x: mouse_x, y: mouse_y}, + cp1: {x: null, y: null}, + cp2: {x: null, y: null}, + end: {x: null, y: null} + }, + params: this.clone(this.getParams()), + render_function: [this.name, 'render'], + x: 0, + y: 0, + width: null, + height: null, + hide_selection_if_active: true, + rotate: null, + is_vector: true, + color: config.COLOR, + status: 'draft', + }; + app.State.do_action( + new app.Actions.Bundle_action('new_bezier_layer', 'New Bezier Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + this.params_hash = params_hash; + } + else { + //add more data + config.layer.data.end.x = mouse_x; + config.layer.data.end.y = mouse_y; + } + + this.Base_layers.render(); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(mouse.click_x); + var click_y = Math.round(mouse.click_y); + + if (mouse.click_valid == false) { + return; + } + if (mouse.is_drag == false) { + return; + } + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x - click_x; + var height = mouse_y - click_y; + + if (Math.abs(width) > Math.abs(height)) + mouse_y = click_y; + else + mouse_x = click_x; + } + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + //add more data + if(config.layer.data.end.x === null){ + //still first step + config.layer.data.cp1.x = mouse_x; + config.layer.data.cp1.y = mouse_y; + } + else{ + config.layer.data.cp2.x = mouse_x; + config.layer.data.cp2.y = mouse_y; + } + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(mouse.click_x); + var click_y = Math.round(mouse.click_y); + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x - click_x; + var height = mouse_y - click_y; + + if (Math.abs(width) > Math.abs(height)) + mouse_y = click_y; + else + mouse_x = click_x; + } + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + //add more data + if(config.layer.data.end.x === null){ + //still first step + config.layer.data.cp1.x = mouse_x; + config.layer.data.cp1.y = mouse_y; + } + else{ + config.layer.data.cp2.x = mouse_x; + config.layer.data.cp2.y = mouse_y; + config.layer.status = null; + } + + this.Base_layers.render(); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + + //also draw control lines + if(config.layer.type == this.name){ + var bezier = config.layer.data; + this.selected_obj_positions = {}; + + var x = config.layer.x; + var y = config.layer.y; + + //draw corners + if (bezier.start.x != null) { + this.Helper.draw_special_line( + this.ctx, + x + bezier.start.x, + y + bezier.start.y, + x + bezier.cp1.x, + y + bezier.cp1.y + ); + if(config.TOOL.name == 'select') { + this.selected_obj_positions.cp1_start = this.Helper.draw_control_point( + this.ctx, + x + bezier.start.x, + y + bezier.start.y + ); + this.selected_obj_positions.cp1_end = this.Helper.draw_control_point( + this.ctx, + x + bezier.cp1.x, + y + bezier.cp1.y + ); + } + } + if (bezier.end.x != null && bezier.cp2.x != null) { + this.Helper.draw_special_line( + this.ctx, + x + bezier.end.x, + y + bezier.end.y, + x + bezier.cp2.x, + y + bezier.cp2.y + ); + if(config.TOOL.name == 'select') { + this.selected_obj_positions.cp2_start = this.Helper.draw_control_point( + this.ctx, + x + bezier.end.x, + y + bezier.end.y + ); + this.selected_obj_positions.cp2_end = this.Helper.draw_control_point( + this.ctx, + x + bezier.cp2.x, + y + bezier.cp2.y + ); + } + } + } + } + + select(ctx) { + this.render_overlay(ctx); + } + + demo(ctx, x, y, width, height) { + var data = { + start: {x: x, y: y}, + cp1: {x: x + width, y: y}, + cp2: {x: x, y: y + height}, + end: {x: x + width, y: y + height} + }; + + this.draw_bezier(ctx, 0, 0, data, 2, '#555'); + } + + render(ctx, layer) { + var params = layer.params; + this.draw_bezier(ctx, layer.x, layer.y, layer.data, params.size, layer.color); + } + + draw_bezier(ctx, x, y, data, lineWidth, color) { + if(data.end.x == null || data.cp2.x == null){ + return; + } + + //set styles + ctx.fillStyle = color; + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.lineCap = 'round'; + + //draw bezier + ctx.beginPath(); + ctx.moveTo(x + data.start.x, y + data.start.y); + ctx.bezierCurveTo( + x + data.cp1.x, y + data.cp1.y, + x + data.cp2.x, y + data.cp2.y, + x + data.end.x, y + data.end.y + ); + ctx.stroke(); + } + + selected_object_actions(e) { + if(config.TOOL.name != 'select' || config.layer.type != this.name || config.layer.status == 'draft'){ + return; + } + + var ctx = this.Base_layers.ctx; + + var mouse = this.get_mouse_info(e); + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(mouse.click_x); + var click_y = Math.round(mouse.click_y); + + const mainWrapper = document.getElementById('main_wrapper'); + + //simplify checks + var event_type = e.type; + if(event_type == 'touchstart') event_type = 'mousedown'; + if(event_type == 'touchmove') event_type = 'mousemove'; + if(event_type == 'touchend') event_type = 'mouseup'; + + if (event_type == 'mouseup') { + //reset + config.mouse_lock = null; + if (mainWrapper.style.cursor != 'default') { + mainWrapper.style.cursor = 'default'; + } + } + + if (event_type == 'mousedown' && config.mouse.valid == false) { + return; + } + + if (event_type == 'mousemove' && this.mouse_lock == 'move_point' && mouse.is_drag) { + mainWrapper.style.cursor = "move"; + + if (e.buttons == 1 || typeof e.buttons == "undefined") { + var type = this.selected_object_drag_type; + var bezier = config.layer.data; + + // Do transformations + var dx = Math.round(mouse.x - mouse.click_x) - config.layer.x; + var dy = Math.round(mouse.y - mouse.click_y) - config.layer.y; + + // Set values + if(type == 'cp1_start') { + bezier.start.x = mouse.click_x + dx; + bezier.start.y = mouse.click_y + dy; + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x - bezier.cp1.x; + var height = mouse_y - bezier.cp1.y; + if (Math.abs(width) > Math.abs(height)) + bezier.start.y = bezier.cp1.y; + else + bezier.start.x = bezier.cp1.x; + } + } + else if(type == 'cp1_end') { + bezier.cp1.x = mouse.click_x + dx; + bezier.cp1.y = mouse.click_y + dy; + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x -bezier.start.x; + var height = mouse_y - bezier.start.y; + if (Math.abs(width) > Math.abs(height)) + bezier.cp1.y = bezier.start.y; + else + bezier.cp1.x = bezier.start.x; + } + } + else if(type == 'cp2_start') { + bezier.end.x = mouse.click_x+ dx; + bezier.end.y = mouse.click_y + dy; + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x - bezier.cp2.x; + var height = mouse_y - bezier.cp2.y; + if (Math.abs(width) > Math.abs(height)) + bezier.end.y = bezier.cp2.y; + else + bezier.end.x = bezier.cp2.x; + } + } + else if(type == 'cp2_end') { + bezier.cp2.x = mouse.click_x + dx; + bezier.cp2.y = mouse.click_y + dy; + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + var width = mouse_x - bezier.end.x; + var height = mouse_y - bezier.end.y; + if (Math.abs(width) > Math.abs(height)) + bezier.cp2.y = bezier.end.y; + else + bezier.cp2.x = bezier.end.x; + } + } + + config.need_render = true; + } + return; + } + if (event_type == 'mouseup' && this.mouse_lock == 'move_point') { + this.mouse_lock = null; + var type = this.selected_object_drag_type; + var bezier = config.layer.data; + + //reset sate + config.layer.data = this.old_data; + + //save state + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + data: bezier, + }) + ]) + ); + + config.need_render = true; + } + + if (!mouse.is_drag && ['mousedown', 'mouseup'].includes(event_type)) { + return; + } + + if (!this.mouse_lock) { + for (let current_drag_type in this.selected_obj_positions) { + const position = this.selected_obj_positions[current_drag_type]; + if (position && this.ctx.isPointInPath(position, mouse.x, mouse.y)) { + // match + if (event_type == 'mousedown') { + if (e.buttons == 1 || typeof e.buttons == "undefined") { + this.mouse_lock = 'move_point'; + this.selected_object_drag_type = current_drag_type; + } + config.mouse_lock = true; + this.old_data = JSON.parse(JSON.stringify(config.layer.data)); + } + if (event_type == 'mousemove') { + mainWrapper.style.cursor = 'move'; + } + } + } + } + } + +} + +export default Bezier_Curve_class; diff --git a/paintplus/frontend/src/js/tools/shapes/callout.js b/paintplus/frontend/src/js/tools/shapes/callout.js new file mode 100644 index 0000000..761efdf --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/callout.js @@ -0,0 +1,99 @@ +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Callout_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'callout'; + this.layer = {}; + this.best_ratio = 1.3; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.moveTo(x, y); + ctx.lineTo(x + width, y); + ctx.lineTo(x + width, y + height * 0.6); + + ctx.lineTo(x + width / 2 + width / 10, y + height * 0.6); + ctx.lineTo(x + width / 8, y + height); + ctx.lineTo(x + width / 2 - width / 10, y + height * 0.6); + + ctx.lineTo(x, y + height * 0.6); + ctx.lineTo(x, y); + + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Callout_class; diff --git a/paintplus/frontend/src/js/tools/shapes/cog.js b/paintplus/frontend/src/js/tools/shapes/cog.js new file mode 100644 index 0000000..6030f5c --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/cog.js @@ -0,0 +1,82 @@ +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Cog_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'cog'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#777'; + ctx.lineWidth = 1; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + + ctx.save(); + + //set styles + ctx.fillStyle = params.fill_color; + ctx.lineWidth = 1; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + ctx.beginPath(); + + //better dont do this, there will be issues with border size + ctx.scale(width/512, height/500); + ctx.translate(-256, -252); + + //SVG path + var p = new Path2D("M190.883 502.932c-4.517 0-9.082-.991-13.368-3.055l-63.216-30.438c-13.348-6.426-20.255-21.479-16.422-35.794 3.684-13.757 8.609-29.81 14.376-46.879a195.425 195.425 0 0 1-15.733-19.711c-17.979 1.837-34.736 3.07-48.937 3.594-14.773.515-27.899-9.536-31.195-23.975L.776 278.273c-3.297-14.444 4.167-29.229 17.748-35.156 13.056-5.697 28.669-11.851 45.59-17.977a193.78 193.78 0 0 1 5.601-24.614c-12.655-12.922-24.061-25.246-33.297-35.989-9.643-11.217-9.939-27.761-.706-39.339l43.744-54.854c9.239-11.584 25.453-14.963 38.552-8.043 12.556 6.636 27.096 15.004 42.466 24.433a194.25 194.25 0 0 1 22.746-10.969c2.209-17.923 4.735-34.522 7.381-48.468 2.757-14.532 15.506-25.079 30.315-25.079h70.161c14.815 0 27.569 10.567 30.325 25.126 2.646 13.983 5.17 30.564 7.377 48.422a193.854 193.854 0 0 1 22.75 10.971c15.42-9.466 29.975-17.843 42.506-24.458 13.079-6.901 29.275-3.512 38.509 8.066l43.743 54.855c9.237 11.582 8.928 28.142-.738 39.374-9.254 10.756-20.646 23.066-33.263 35.957a193.79 193.79 0 0 1 5.601 24.62c16.986 6.145 32.615 12.304 45.634 17.992h.001c13.553 5.923 20.997 20.699 17.701 35.137l-15.615 68.4c-3.299 14.446-16.455 24.532-31.247 23.972-14.229-.531-30.97-1.762-48.889-3.588a195.251 195.251 0 0 1-15.728 19.703c5.791 17.122 10.723 33.189 14.394 46.921 3.819 14.291-3.093 29.324-16.436 35.748l-63.214 30.438c-13.351 6.428-29.426 2.438-38.224-9.484-8.455-11.455-17.931-25.313-27.679-40.466-8.425.548-16.745.548-25.176 0-9.772 15.201-19.257 29.075-27.702 40.508-5.964 8.075-15.283 12.499-24.824 12.5zm-61.851-61.915l61.516 29.619c15.437-20.988 29.097-42.937 36.43-54.579 26.665 3.104 31.829 3.053 58.035.001 6.932 10.997 20.8 33.291 36.445 54.576l61.515-29.619c-6.794-25.207-15.471-49.669-19.957-62.54 19.028-18.834 22.066-22.637 36.219-45.367 13.048 1.451 39.007 4.495 65.388 5.533l15.195-66.562c-24.034-10.441-48.695-18.946-61.337-23.387-2.824-26.58-3.888-31.341-12.882-56.619 9.27-9.299 27.886-27.753 45.083-47.657l-42.566-53.381c-22.622 12.001-44 25.528-56.513 33.37-22.495-14.328-26.889-16.481-52.31-25.228-1.474-12.904-4.292-38.972-9.156-64.958H221.86c-4.53 24.145-7.144 47.395-9.144 64.955-25.185 8.667-29.587 10.755-52.309 25.223-11.055-6.923-33.256-21.009-56.521-33.362l-42.568 53.379c16.896 19.57 35.133 37.669 45.088 47.647-8.943 25.131-10.043 29.878-12.885 56.613-12.366 4.348-37.104 12.879-61.339 23.397l15.192 66.562c25.642-.998 50.721-3.907 65.381-5.542 14.147 22.727 17.192 26.54 36.221 45.377-4.265 12.257-13.059 37.034-19.944 62.549zm351.667-168.554l.009.004-.009-.004zM256 347.486c-50.446 0-91.486-41.041-91.486-91.486s41.041-91.486 91.486-91.486c50.445 0 91.486 41.041 91.486 91.486S306.445 347.486 256 347.486zm0-150.972c-32.801 0-59.486 26.686-59.486 59.486S223.2 315.486 256 315.486 315.486 288.8 315.486 256 288.801 196.514 256 196.514z"); + + ctx.closePath(); + ctx.fill(p); + } + +} + +export default Cog_class; diff --git a/paintplus/frontend/src/js/tools/shapes/cylinder.js b/paintplus/frontend/src/js/tools/shapes/cylinder.js new file mode 100644 index 0000000..a87cfc9 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/cylinder.js @@ -0,0 +1,100 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Cylinder_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'cylinder'; + this.layer = {}; + this.best_ratio = 0.7; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.scale(1, 1.20); + ctx.translate(-width / 2, -height / 2); + + var dh = height/3; + + ctx.moveTo(0, dh); + ctx.bezierCurveTo(0,dh+dh, width,dh+dh, width,dh); + ctx.bezierCurveTo(width,dh-dh, 0,dh-dh, 0,dh); + ctx.lineTo(0, height-dh); + ctx.bezierCurveTo(0,height-dh+dh, width,height-dh+dh, width,height-dh); + ctx.lineTo(width, dh); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Cylinder_class; diff --git a/paintplus/frontend/src/js/tools/shapes/ellipse.js b/paintplus/frontend/src/js/tools/shapes/ellipse.js new file mode 100644 index 0000000..a5d3fc4 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/ellipse.js @@ -0,0 +1,262 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Ellipse_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'ellipse'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.mouse_click = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) + return; + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + this.mouse_click.x = mouse_x; + this.mouse_click.y = mouse_y; + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: mouse_x, + y: mouse_y, + color: null, + is_vector: true, + }; + if (params.circle == true) { + //disable rotate + this.layer.rotate = null; + } + app.State.do_action( + new app.Actions.Bundle_action('new_ellipse_layer', 'New Ellipse Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (params.circle == true || e.ctrlKey == true || e.metaKey) { + if (width < height) { + width = height; + } + else { + height = width; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + //more data + config.layer.x = x; + config.layer.y = y; + config.layer.width = width; + config.layer.height = height; + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (params.circle == true || e.ctrlKey == true || e.metaKey) { + if (width < height) { + width = height; + } + else { + height = width; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + //more data + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x: x, + y: y, + width: width, + height: height, + status: null + }), + { merge_with_history: 'new_ellipse_layer' } + ); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + x = parseInt(x); + y = parseInt(y); + width = parseInt(width); + height = parseInt(height); + + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 3; + + this.ellipse( + ctx, + x, + y, + width, + height, + true, + true + ); + } + + render(ctx, layer) { + var params = layer.params; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + var dist_x = layer.width; + var dist_y = layer.height; + + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.ellipse(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.border, params.fill); + + ctx.restore(); + } + + ellipse(ctx, x, y, w, h, stroke, fill) { + var kappa = .5522848, + ox = (w / 2) * kappa, // control point offset horizontal + oy = (h / 2) * kappa, // control point offset vertical + xe = x + w, // x-end + ye = y + h, // y-end + xm = x + w / 2, // x-middle + ym = y + h / 2; // y-middle + + ctx.beginPath(); + ctx.moveTo(x, ym); + ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y); + ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym); + ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye); + ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym); + ctx.closePath(); + if ( stroke == true) + ctx.stroke(); + if (fill == true) + ctx.fill(); + } + +} + +export default Ellipse_class; diff --git a/paintplus/frontend/src/js/tools/shapes/heart.js b/paintplus/frontend/src/js/tools/shapes/heart.js new file mode 100644 index 0000000..bfb744d --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/heart.js @@ -0,0 +1,106 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Heart_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'heart'; + this.layer = {}; + this.best_ratio = 1.2; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.scale(1.071, 1.1); + ctx.translate(-width / 2, -height / 1.85); + + ctx.moveTo(width/2, height/5); + ctx.bezierCurveTo(5 * width / 14, 0, + 0, height / 15, + width / 28, 2 * height / 5); + + ctx.bezierCurveTo(width / 14, 2 * height / 3, + 3 * width / 7, 5 * height / 6, + width / 2, height); + + ctx.bezierCurveTo(4 * width / 7, 5 * height / 6, + 13 * width / 14, 2 * height / 3, + 27 * width / 28, 2 * height / 5); + + ctx.bezierCurveTo(width, height / 15, + 9 * width / 14, 0, + width / 2, height / 5); + + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Heart_class; diff --git a/paintplus/frontend/src/js/tools/shapes/hexagon.js b/paintplus/frontend/src/js/tools/shapes/hexagon.js new file mode 100644 index 0000000..ead2442 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/hexagon.js @@ -0,0 +1,114 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Hexagon_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'hexagon'; + this.layer = {}; + this.best_ratio = 1.1547005; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [75, 6.698729810778069], + [100, 50], + [75, 93.30127018922192], + [24.99999999999999, 93.30127018922192], + [0, 50.00000000000001], + [24.99999999999998, 6.698729810778076], + [75, 6.698729810778069], + [75, 6.698729810778069], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + this.draw_shape(ctx, x, y - 5, width, height, this.coords); + + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.scale(1, this.best_ratio); + + for(var i in coords){ + if(coords[i] === null){ + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + continue; + } + + //coords in 100x100 box + var pos_x = x + coords[i][0] * width / 100; + var pos_y = y + coords[i][1] * height / 100; + + if(i == '0') + ctx.moveTo(pos_x, pos_y); + else + ctx.lineTo(pos_x, pos_y); + } + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Hexagon_class; diff --git a/paintplus/frontend/src/js/tools/shapes/human.js b/paintplus/frontend/src/js/tools/shapes/human.js new file mode 100644 index 0000000..07a2653 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/human.js @@ -0,0 +1,110 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Human_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'human'; + this.layer = {}; + this.best_ratio = 0.35; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.translate(-width / 2, -height / 2); + + var radius = Math.sqrt(width * height) * 0.28; + var neck_height = height * 0.07; + var leg_height = height * 0.3; + if(radius * 2 + neck_height + leg_height > height){ + radius = (height - leg_height - neck_height) / 2; + } + + ctx.arc(width / 2, radius, radius, 0, 2 * Math.PI); + //body + ctx.moveTo(width / 2, radius * 2); + ctx.lineTo(width / 2, height - leg_height); + //arm + ctx.moveTo(0, radius*2 + neck_height); + ctx.lineTo(width, radius*2 + neck_height); + //left leg + ctx.moveTo(width / 2, height - leg_height); + ctx.lineTo(0, height); + //right leg + ctx.moveTo(width / 2, height - leg_height); + ctx.lineTo(width, height); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Human_class; diff --git a/paintplus/frontend/src/js/tools/shapes/line.js b/paintplus/frontend/src/js/tools/shapes/line.js new file mode 100644 index 0000000..d1fd061 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/line.js @@ -0,0 +1,195 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Line_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'line'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.mouse_click = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + this.mouse_click.x = mouse_x; + this.mouse_click.y = mouse_y; + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: mouse_x, + y: mouse_y, + rotate: null, + is_vector: true, + color: config.COLOR + }; + app.State.do_action( + new app.Actions.Bundle_action('new_line_layer', 'New Line Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + var width = mouse_x - this.layer.x; + var height = mouse_y - this.layer.y; + if (e.ctrlKey == true || e.metaKey) { + //one direction only + if (Math.abs(width) < Math.abs(height)) + width = 0; + else + height = 0; + } + + //more data + config.layer.width = width; + config.layer.height = height; + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + + var width = mouse_x - this.layer.x; + var height = mouse_y - this.layer.y; + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + if (e.ctrlKey == true || e.metaKey) { + //one direction only + if (Math.abs(width) < Math.abs(height)) + width = 0; + else + height = 0; + } + + //more data + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + width, + height, + status: null + }), + { merge_with_history: 'new_line_layer' } + ); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + var coords = [ + [0, 0], + [100, 100], + ]; + this.draw_shape(ctx, x, y, width, height, coords); + } + + render(ctx, layer) { + if (layer.width == 0 && layer.height == 0) + return; + + var params = layer.params; + + //set styles + ctx.fillStyle = layer.color; + ctx.strokeStyle = layer.color; + ctx.lineWidth = params.size; + ctx.lineCap = 'round'; + + var width = layer.x + layer.width; + var height = layer.y + layer.height; + + //draw line + ctx.beginPath(); + ctx.moveTo(layer.x, layer.y); + ctx.lineTo(width, height); + ctx.stroke(); + } + +} + +export default Line_class; diff --git a/paintplus/frontend/src/js/tools/shapes/moon.js b/paintplus/frontend/src/js/tools/shapes/moon.js new file mode 100644 index 0000000..8e57333 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/moon.js @@ -0,0 +1,120 @@ +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Moon_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'moon'; + this.layer = {}; + this.best_ratio = 0.8; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height, true, true); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.fill, params.border); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, fill, stroke) { + var left = parseInt(x); + var top = parseInt(y); + + ctx.beginPath(); + ctx.moveTo(left + width * 0.512, top + height / 2); + ctx.bezierCurveTo( + left + width * 51.2 / 100, top + height * 28.4 / 100, + left + width * 71.5 / 100, top + height * 10.1 / 100, + left + width * 100 / 100, top + height * 3.1 / 100 + ); + ctx.bezierCurveTo( + left + width * 92 / 100, top + height * 1.1 / 100, + left + width * 83.4 / 100, top + height * 0 / 100, + left + width * 74.4 / 100, top + height * 0 / 100 + ); + ctx.bezierCurveTo( + left + width * 33.3 / 100, top + height * 0 / 100, + left + width * 0 / 100, top + height * 22.4 / 100, + left + width * 0 / 100, top + height * 50 / 100 + ); + ctx.bezierCurveTo( + left + width * 0 / 100, top + height * 77.6 / 100, + left + width * 33.3 / 100, top + height * 100 / 100, + left + width * 74.4 / 100, top + height * 100 / 100 + ); + ctx.bezierCurveTo( + left + width * 83.4 / 100, top + height * 100 / 100, + left + width * 92 / 100, top + height * 98.9 / 100, + left + width * 100 / 100, top + height * 96.9 / 100 + ); + ctx.bezierCurveTo( + left + width * 71.5 / 100, top + height * 89.9 / 100, + left + width * 51.2 / 100, top + height * 71.6 / 100, + left + width * 51.2 / 100, top + height * 50 / 100 + ); + ctx.closePath(); + if (fill) { + ctx.fill(); + } + if (stroke) { + ctx.stroke(); + } + } + +} + +export default Moon_class; diff --git a/paintplus/frontend/src/js/tools/shapes/parallelogram.js b/paintplus/frontend/src/js/tools/shapes/parallelogram.js new file mode 100644 index 0000000..5ab9258 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/parallelogram.js @@ -0,0 +1,75 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Parallelogram_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'parallelogram'; + this.layer = {}; + this.best_ratio = 2; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [25, 0], + [100, 0], + [75, 100], + [0, 100], + [25, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Parallelogram_class; diff --git a/paintplus/frontend/src/js/tools/shapes/pentagon.js b/paintplus/frontend/src/js/tools/shapes/pentagon.js new file mode 100644 index 0000000..56e8047 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/pentagon.js @@ -0,0 +1,112 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Pentagon_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'pentagon'; + this.layer = {}; + this.best_ratio = 1.051; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [100.40599536364314, 38.90073974812779], + [81.15261837150108, 98.1565411518722], + [18.84738162849893, 98.1565411518722], + [-0.40599536364314304, 38.90073974812779], + [49.99999999999999, 2.2786404499999975], + [100.40599536364314, 38.900739748127776], + [100.40599536364314, 38.90073974812779], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, coords) { + ctx.lineJoin = "round"; + + ctx.beginPath(); + + ctx.scale(1, 1.051); + + for(var i in coords){ + if(coords[i] === null){ + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + continue; + } + + //coords in 100x100 box + var pos_x = x + coords[i][0] * width / 100; + var pos_y = y + coords[i][1] * height / 100; + + if(i == '0') + ctx.moveTo(pos_x, pos_y); + else + ctx.lineTo(pos_x, pos_y); + } + ctx.closePath(); + + ctx.fill(); + ctx.stroke(); + } + +} + +export default Pentagon_class; diff --git a/paintplus/frontend/src/js/tools/shapes/plus.js b/paintplus/frontend/src/js/tools/shapes/plus.js new file mode 100644 index 0000000..e238ee4 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/plus.js @@ -0,0 +1,85 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; + +class Plus_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'plus'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [35, 0], + [65, 0], + [65, 35], + [100, 35], + [100, 65], + [65, 65], + [65, 100], + [35, 100], + [35, 65], + [0, 65], + [0, 35], + [35, 35], + [35, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Plus_class; diff --git a/paintplus/frontend/src/js/tools/shapes/polygon.js b/paintplus/frontend/src/js/tools/shapes/polygon.js new file mode 100644 index 0000000..f4576fb --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/polygon.js @@ -0,0 +1,369 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; +import Helper_class from './../../libs/helpers.js'; + +class Polygon_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'polygon'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.params_hash = false; + this.selected_obj_positions = {}; + this.mouse_lock = null; + this.selected_object_drag_type = null; + this.old_data = null; + + this.events(); + } + + load() { + var _this = this; + this.default_events(); + document.addEventListener('keydown', function (event) { + var code = event.code; + if (config.TOOL.name == _this.name && code == "Escape") { + //escape + config.layer.status = null; + } + }); + } + + /** + * events for handling helping lines only + */ + events() { + document.addEventListener('mousedown', (e) => { + this.selected_object_actions(e); + }); + document.addEventListener('mousemove', (e) => { + this.selected_object_actions(e); + }); + document.addEventListener('mouseup', (e) => { + this.selected_object_actions(e); + }); + + // touch + document.addEventListener('touchstart', (event) => { + this.selected_object_actions(event); + }); + document.addEventListener('touchmove', (event) => { + this.selected_object_actions(event); + }, {passive: false}); + document.addEventListener('touchend', (event) => { + this.selected_object_actions(event); + }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + + var params_hash = this.get_params_hash(); + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + if (config.layer.type != this.name || params_hash != this.params_hash + || (config.layer.data != null && config.layer.status != 'draft')) { + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + data: [ + {x: mouse_x, y: mouse_y} + ], + params: this.clone(this.getParams()), + render_function: [this.name, 'render'], + x: 0, + y: 0, + width: null, + height: null, + hide_selection_if_active: true, + rotate: null, + is_vector: true, + color: null, + status: 'draft', + }; + app.State.do_action( + new app.Actions.Bundle_action('new_polygon_layer', 'New Polygon Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + this.params_hash = params_hash; + } + else { + //add more data + config.layer.data.push( + {x: mouse_x, y: mouse_y} + ); + } + + this.Base_layers.render(); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + + if (mouse.click_valid == false) { + return; + } + if (mouse.is_drag == false) { + return; + } + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + //add more data + config.layer.data[config.layer.data.length - 1] = {x: mouse_x, y: mouse_y}; + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + //add more data + config.layer.data[config.layer.data.length - 1] = {x: mouse_x, y: mouse_y}; + + this.Base_layers.render(); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + + if(config.TOOL.name != 'select'){ + return; + } + + //also draw control lines + if(config.layer.type == this.name){ + var data = config.layer.data; + this.selected_obj_positions = {}; + + //draw corners + for(var i in data) { + var point = data[i]; + + this.selected_obj_positions[i] = this.Helper.draw_control_point( + this.ctx, + config.layer.x + point.x, + config.layer.y + point.y + ); + } + } + } + + select(ctx) { + this.render_overlay(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + var data = [ + {x: 0, y: 0}, + {x: width, y: 0}, + {x: width * 1.1, y: height * 2 / 3}, + {x: width / 2, y: height / 3}, + {x: -1 * width * 0.2, y: height}, + ]; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_polygon(ctx, -width / 2, -height / 2, width, height, data); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_polygon(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, layer.data); + + ctx.restore(); + } + + draw_polygon(ctx, x, y, width, height, data) { + if(data.length == 0){ + return; + } + + //draw + ctx.beginPath(); + for(var i in data) { + if(i == 0){ + ctx.moveTo(x + data[i].x, y + data[i].y); + } + else{ + ctx.lineTo(x + data[i].x, y + data[i].y); + } + } + ctx.closePath(); + ctx.fill() + ctx.stroke(); + } + + selected_object_actions(e) { + if(config.TOOL.name != 'select' || config.layer.type != this.name){ + return; + } + + var ctx = this.Base_layers.ctx; + var mouse = this.get_mouse_info(e); + const mainWrapper = document.getElementById('main_wrapper'); + + //simplify checks + var event_type = e.type; + if(event_type == 'touchstart') event_type = 'mousedown'; + if(event_type == 'touchmove') event_type = 'mousemove'; + if(event_type == 'touchend') event_type = 'mouseup'; + + if (event_type == 'mouseup') { + //reset + config.mouse_lock = null; + if (mainWrapper.style.cursor != 'default') { + mainWrapper.style.cursor = 'default'; + } + } + + if (event_type == 'mousedown' && config.mouse.valid == false) { + return; + } + + if (event_type == 'mousemove' && this.mouse_lock == 'move_point' && mouse.is_drag) { + mainWrapper.style.cursor = "move"; + + if (e.buttons == 1 || typeof e.buttons == "undefined") { + var type = this.selected_object_drag_type; + var bezier = config.layer.data; + + // Do transformations + var dx = Math.round(mouse.x - mouse.click_x) - config.layer.x; + var dy = Math.round(mouse.y - mouse.click_y) - config.layer.y; + + // Set values + config.layer.data[type] = { + x: mouse.click_x + dx, + y: mouse.click_y + dy + }; + + config.need_render = true; + } + return; + } + if (event_type == 'mouseup' && this.mouse_lock == 'move_point') { + this.mouse_lock = null; + var bezier = config.layer.data; + + //reset sate + config.layer.data = this.old_data; + + //save state + app.State.do_action( + new app.Actions.Bundle_action('change_layer_details', 'Change Layer Details', [ + new app.Actions.Update_layer_action(config.layer.id, { + data: bezier, + }) + ]) + ); + + config.need_render = true; + } + + if (!mouse.is_drag && ['mousedown', 'mouseup'].includes(event_type)) { + return; + } + + if (!this.mouse_lock) { + for (let current_drag_type in this.selected_obj_positions) { + const position = this.selected_obj_positions[current_drag_type]; + if (position && this.ctx.isPointInPath(position, mouse.x, mouse.y)) { + // match + if (event_type == 'mousedown') { + if (e.buttons == 1 || typeof e.buttons == "undefined") { + this.mouse_lock = 'move_point'; + this.selected_object_drag_type = current_drag_type; + } + config.mouse_lock = true; + this.old_data = JSON.parse(JSON.stringify(config.layer.data)); + } + if (event_type == 'mousemove') { + mainWrapper.style.cursor = 'move'; + } + } + } + } + } + +} + +export default Polygon_class; diff --git a/paintplus/frontend/src/js/tools/shapes/rectangle.js b/paintplus/frontend/src/js/tools/shapes/rectangle.js new file mode 100644 index 0000000..0102f4e --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/rectangle.js @@ -0,0 +1,302 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Rectangle_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'rectangle'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.mouse_click = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + var mouse_x = mouse.x; + var mouse_y = mouse.y; + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + this.mouse_click.x = mouse_x; + this.mouse_click.y = mouse_y; + + //register new object - current layer is not ours or params changed + this.layer = { + type: this.name, + params: this.clone(this.getParams()), + status: 'draft', + render_function: [this.name, 'render'], + x: Math.round(mouse_x), + y: Math.round(mouse_y), + color: null, + is_vector: true + }; + app.State.do_action( + new app.Actions.Bundle_action('new_rectangle_layer', 'New Rectangle Layer', [ + new app.Actions.Insert_layer_action(this.layer) + ]) + ); + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (params.square == true || e.ctrlKey == true || e.metaKey) { + if (width < height) { + width = height; + } + else { + height = width; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + //more data + config.layer.x = x; + config.layer.y = y; + config.layer.width = width; + config.layer.height = height; + + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + + if (mouse.click_valid == false) { + config.layer.status = null; + return; + } + + var mouse_x = Math.round(mouse.x); + var mouse_y = Math.round(mouse.y); + var click_x = Math.round(this.mouse_click.x); + var click_y = Math.round(this.mouse_click.y); + + //apply snap + var snap_info = this.calc_snap_position(e, mouse_x, mouse_y, config.layer.id); + if(snap_info != null){ + if(snap_info.x != null) { + mouse_x = snap_info.x; + } + if(snap_info.y != null) { + mouse_y = snap_info.y; + } + } + this.snap_line_info = {x: null, y: null}; + + var x = Math.min(mouse_x, click_x); + var y = Math.min(mouse_y, click_y); + var width = Math.abs(mouse_x - click_x); + var height = Math.abs(mouse_y - click_y); + + if (params.square == true || e.ctrlKey == true || e.metaKey) { + if (width < height) { + width = height; + } + else { + height = width; + } + if (mouse_x < click_x) { + x = click_x - width; + } + if (mouse_y < click_y) { + y = click_y - height; + } + } + + if (width == 0 && height == 0) { + //same coordinates - cancel + app.State.scrap_last_action(); + return; + } + + //more data + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x, + y, + width, + height, + status: null + }), + { merge_with_history: 'new_rectangle_layer' } + ); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + var coords = [ + [0, 0], + [100, 0], + [100, 100], + [0, 100], + [0, 0], + ]; + this.draw_shape(ctx, x, y, width, height, coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + var stroke = params.border; + var rotateSupport = true; + var radius = params.radius; + if(radius == undefined) + radius = 0; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + if (rotateSupport == false) { + this.roundRect(ctx, layer.x, layer.y, layer.width, layer.height, radius, fill, stroke); + } + else { + //rotate + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.roundRect(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, radius, fill, stroke); + } + + ctx.restore(); + } + + /** + * Draws a rounded rectangle on canvas. + * + * @param {CanvasRenderingContext2D} ctx + * @param {Number} x + * @param {Number} y + * @param {Number} width + * @param {Number} height + * @param {Number} radius + * @param {Boolean} fill + */ + roundRect(ctx, x, y, width, height, radius, fill, stroke) { + x = parseInt(x); + y = parseInt(y); + width = parseInt(width); + height = parseInt(height); + if(width < 0){ + width = Math.abs(width); + x = x - width; + } + if(height < 0){ + height = Math.abs(height); + y = y - height; + } + var smaller_dimension = Math.min(width, height); + + radius = parseInt(radius); + if (typeof fill == 'undefined') { + fill = false; + } + if (typeof radius === 'undefined') { + radius = 0; + } + radius = Math.min(radius, width / 2, height / 2); + radius = Math.floor(radius); + + // Odd dimensions must draw offset half a pixel + if (width % 2 == 1 && config.layer.status != 'draft') { + x -= 0.5; + } + if (height % 2 == 1 && config.layer.status != 'draft') { + y -= 0.5; + } + + var stroke_offset = !fill && ctx.lineWidth % 2 == 1 && width > 1 && height > 1 ? 0.5 : 0; + + if (smaller_dimension < 2) fill = true; + + radius = {tl: radius, tr: radius, br: radius, bl: radius}; + ctx.beginPath(); + ctx.moveTo(x + radius.tl + stroke_offset, y + stroke_offset); + ctx.lineTo(x + width - radius.tr - stroke_offset, y + stroke_offset); + ctx.quadraticCurveTo(x + width - stroke_offset, y + stroke_offset, x + width - stroke_offset, y + radius.tr + stroke_offset); + ctx.lineTo(x + width - stroke_offset, y + height - radius.br - stroke_offset); + ctx.quadraticCurveTo(x + width - stroke_offset, y + height - stroke_offset, x + width - radius.br - stroke_offset, y + height - stroke_offset); + ctx.lineTo(x + radius.bl + stroke_offset, y + height - stroke_offset); + ctx.quadraticCurveTo(x + stroke_offset, y + height - stroke_offset, x + stroke_offset, y + height - radius.bl - stroke_offset); + ctx.lineTo(x + stroke_offset, y + radius.tl + stroke_offset); + ctx.quadraticCurveTo(x + stroke_offset, y + stroke_offset, x + radius.tl + stroke_offset, y + stroke_offset); + ctx.closePath(); + if (fill) { + ctx.fill(); + } + if (stroke) { + ctx.stroke(); + } + } + +} + +export default Rectangle_class; diff --git a/paintplus/frontend/src/js/tools/shapes/right_triangle.js b/paintplus/frontend/src/js/tools/shapes/right_triangle.js new file mode 100644 index 0000000..de85a81 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/right_triangle.js @@ -0,0 +1,74 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Right_Triangle_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'right_triangle'; + this.layer = {}; + this.best_ratio = 1; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [0, 0], + [100, 100], + [0, 100], + [0, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Right_Triangle_class; diff --git a/paintplus/frontend/src/js/tools/shapes/romb.js b/paintplus/frontend/src/js/tools/shapes/romb.js new file mode 100644 index 0000000..7b92e9d --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/romb.js @@ -0,0 +1,82 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Romb_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'romb'; + this.layer = {}; + this.best_ratio = 0.8; + this.snap_line_info = {x: null, y: null}; + this.coords_demo = [ + [50, 0], + [80, 50], + [50, 100], + [20, 50], + [50, 0], + ]; + this.coords = [ + [50, 0], + [100, 50], + [50, 100], + [0, 50], + [50, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords_demo); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Romb_class; diff --git a/paintplus/frontend/src/js/tools/shapes/star.js b/paintplus/frontend/src/js/tools/shapes/star.js new file mode 100644 index 0000000..93b190d --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/star.js @@ -0,0 +1,113 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Star_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'star'; + this.layer = {}; + this.best_ratio = 1; + this.coords = []; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + generate_coords(spikes, innerRadius) { + //settings + + innerRadius = parseInt(innerRadius) / 2; + innerRadius = Math.min(Math.max(innerRadius, 0), 100); + + spikes = parseInt(spikes); + spikes = Math.max(spikes, 3); + + var outerRadius = 50; + if(spikes == 5){ + outerRadius = 53; + } + + var cx = 50; + + var cy = 50; + if(spikes == 5){ + cy = 55; + } + + var rot = Math.PI / 2 * 3; + var x = cx; + var y = cy; + var step = Math.PI / spikes; + this.coords = []; + this.coords.push([cx, cy - outerRadius]); + for (var i = 0; i < spikes; i++) { + x = cx + Math.cos(rot) * outerRadius; + y = cy + Math.sin(rot) * outerRadius; + this.coords.push([x, y]); + rot += step; + + x = cx + Math.cos(rot) * innerRadius; + y = cy + Math.sin(rot) * innerRadius; + this.coords.push([x, y]); + rot += step; + } + this.coords.push([cx, cy - outerRadius]); + } + + demo(ctx, x, y, width, height) { + this.generate_coords(5, 40); + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + this.generate_coords(params.corners, params.inner_radius); + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Star_class; diff --git a/paintplus/frontend/src/js/tools/shapes/tear.js b/paintplus/frontend/src/js/tools/shapes/tear.js new file mode 100644 index 0000000..fbaead4 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/tear.js @@ -0,0 +1,115 @@ +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Tear_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'tear'; + this.layer = {}; + this.best_ratio = 0.7; + this.snap_line_info = {x: null, y: null}; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + ctx.fillStyle = '#aaa'; + ctx.strokeStyle = '#555'; + ctx.lineWidth = 2; + + var width_all = width + x * 2; + width = height * this.best_ratio; + x = (width_all - width) / 2; + + ctx.save(); + ctx.translate(x + width / 2, y + height / 2); + this.draw_shape(ctx, -width / 2, -height / 2, width, height, true, true); + ctx.restore(); + } + + render(ctx, layer) { + var params = layer.params; + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, params.fill, params.border); + + ctx.restore(); + } + + draw_shape(ctx, x, y, width, height, fill, stroke) { + var left = parseInt(x); + var top = parseInt(y); + + //settings + var curve_height = 29 / 100; + var curve_start_x = 28 / 100; + var curve_end_x = 1 - curve_start_x; + var curve_cdx = 70; + var curve_cdy = 58; + + ctx.beginPath(); + ctx.moveTo(left + width * 0.5, top); + ctx.quadraticCurveTo( + left + width * 0.5, top + height * 13 / 100, + left + width * curve_end_x, top + height * curve_height + ); + ctx.bezierCurveTo( + left + width * (50 + curve_cdx) / 100, top + height * curve_cdy / 100, + left + width * 100 / 100, top + height * 100 / 100, + left + width * 0.5, top + height + ); + ctx.bezierCurveTo( + left + width * 0 / 100, top + height * 100 / 100, + left + width * (50 - curve_cdx) / 100, top + height * curve_cdy / 100, + left + width * curve_start_x, top + height * curve_height + ); + ctx.quadraticCurveTo( + left + width * 0.5, top + height * 13 / 100, + left + width * 0.5, top + ); + ctx.closePath(); + if (fill) { + ctx.fill(); + } + if (stroke) { + ctx.stroke(); + } + } + +} + +export default Tear_class; diff --git a/paintplus/frontend/src/js/tools/shapes/trapezoid.js b/paintplus/frontend/src/js/tools/shapes/trapezoid.js new file mode 100644 index 0000000..cbb04e8 --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/trapezoid.js @@ -0,0 +1,75 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Trapezoid_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'trapezoid'; + this.layer = {}; + this.best_ratio = 2; + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [20, 0], + [80, 0], + [100, 100], + [0, 100], + [20, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Trapezoid_class; diff --git a/paintplus/frontend/src/js/tools/shapes/triangle.js b/paintplus/frontend/src/js/tools/shapes/triangle.js new file mode 100644 index 0000000..deef4ca --- /dev/null +++ b/paintplus/frontend/src/js/tools/shapes/triangle.js @@ -0,0 +1,74 @@ +import app from './../../app.js'; +import config from './../../config.js'; +import Base_tools_class from './../../core/base-tools.js'; +import Base_layers_class from './../../core/base-layers.js'; + +class Triangle_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.ctx = ctx; + this.name = 'triangle'; + this.layer = {}; + this.best_ratio = 2 / Math.sqrt(3); + this.snap_line_info = {x: null, y: null}; + this.coords = [ + [50, 0], + [100, 100], + [0, 100], + [50, 0], + ]; + } + + load() { + this.default_events(); + } + + mousedown(e) { + this.shape_mousedown(e); + } + + mousemove(e) { + this.shape_mousemove(e); + } + + mouseup(e) { + this.shape_mouseup(e); + } + + render_overlay(ctx){ + var ctx = this.Base_layers.ctx; + this.render_overlay_parent(ctx); + } + + demo(ctx, x, y, width, height) { + this.draw_shape(ctx, x, y, width, height, this.coords); + } + + render(ctx, layer) { + var params = layer.params; + var fill = params.fill; + + ctx.save(); + + //set styles + ctx.strokeStyle = 'transparent'; + ctx.fillStyle = 'transparent'; + if(params.border) + ctx.strokeStyle = params.border_color; + if(params.fill) + ctx.fillStyle = params.fill_color; + ctx.lineWidth = params.border_size; + + //draw with rotation support + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(layer.rotate * Math.PI / 180); + this.draw_shape(ctx, -layer.width / 2, -layer.height / 2, layer.width, layer.height, this.coords, false); + + ctx.restore(); + } + +} + +export default Triangle_class; diff --git a/paintplus/frontend/src/js/tools/sharpen.js b/paintplus/frontend/src/js/tools/sharpen.js new file mode 100644 index 0000000..ad21137 --- /dev/null +++ b/paintplus/frontend/src/js/tools/sharpen.js @@ -0,0 +1,139 @@ +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import ImageFilters from './../libs/imagefilters.js'; +import Helper_class from './../libs/helpers.js'; + +class Sharpen_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'sharpen'; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + this.started = false; + } + + load() { + this.default_events(); + } + + default_dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + + //mouse cursor + var mouse = this.get_mouse_info(event); + var params = this.getParams(); + this.show_mouse_cursor(mouse.x, mouse.y, params.size, 'circle'); + } + + mousedown(e) { + this.started = false; + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.click_valid == false) { + return; + } + if (config.layer.type != 'image') { + alertify.error('This layer must contain an image. Please convert it to raster to apply this tool.'); + return; + } + if (config.layer.rotate || 0 > 0) { + alertify.error('Erase on rotate object is disabled. Please rasterize first.'); + return; + } + this.started = true; + + //get canvas from layer + this.tmpCanvas = document.createElement('canvas'); + this.tmpCanvasCtx = this.tmpCanvas.getContext("2d"); + this.tmpCanvas.width = config.layer.width_original; + this.tmpCanvas.height = config.layer.height_original; + this.tmpCanvasCtx.drawImage(config.layer.link, 0, 0); + + //do sharpen + this.sharpen_general('click', mouse, params.size); + + //register tmp canvas for faster redraw + config.layer.link_canvas = this.tmpCanvas; + config.need_render = true; + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + var params = this.getParams(); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + if (this.started == false) { + return; + } + + //do sharpen + this.sharpen_general('move', mouse, params.size); + + //draw draft preview + config.need_render = true; + } + + mouseup(e) { + if (this.started == false) { + return; + } + delete config.layer.link_canvas; + + app.State.do_action( + new app.Actions.Bundle_action('sharpen_tool', 'Sharpen Tool', [ + new app.Actions.Update_layer_image_action(this.tmpCanvas) + ]) + ); + + //decrease memory + this.tmpCanvas.width = 1; + this.tmpCanvas.height = 1; + this.tmpCanvas = null; + this.tmpCanvasCtx = null; + } + + sharpen_general(type, mouse, size) { + var ctx = this.tmpCanvasCtx; + var mouse_x = Math.round(mouse.x) - config.layer.x; + var mouse_y = Math.round(mouse.y) - config.layer.y; + + //adapt to origin size + mouse_x = this.adaptSize(mouse_x, 'width'); + mouse_y = this.adaptSize(mouse_y, 'height'); + var size_w = this.adaptSize(size, 'width'); + var size_h = this.adaptSize(size, 'height'); + + //find center + var center_x = mouse_x - Math.round(size_w / 2); + var center_y = mouse_y - Math.round(size_h / 2); + + //convert float coords to integers + mouse_x = Math.round(mouse_x); + mouse_y = Math.round(mouse_y); + center_x = Math.round(center_x); + center_y = Math.round(center_y); + + var power = 0.5; + if (type == 'move') { + power = power / 10; + } + + var imageData = ctx.getImageData(center_x, center_y, size_w, size_h); + var filtered = ImageFilters.Sharpen(imageData, power); //add effect + this.Helper.image_round(this.tmpCanvasCtx, mouse_x, mouse_y, size_w, size_h, filtered); + } + +} +export default Sharpen_class; diff --git a/paintplus/frontend/src/js/tools/smart_select.js b/paintplus/frontend/src/js/tools/smart_select.js new file mode 100644 index 0000000..ce0829d --- /dev/null +++ b/paintplus/frontend/src/js/tools/smart_select.js @@ -0,0 +1,680 @@ +/** + * Smart Select Tool - Uses SAM (Segment Anything Model) for AI-powered selection + * Click on any object to automatically select it + * Shift+Click to add to existing selection (multi-select) + * Supports: Copy to layer, Cut to layer, Delete selection, AI Inpaint + */ + +import app from './../app.js'; +import config from './../config.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_layers_class from './../core/base-layers.js'; +import Helper_class from './../libs/helpers.js'; +import Dialog_class from './../libs/popup.js'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; +import apiService from './../services/api.js'; +import { SelectionActions, updateLayerWithResult } from './selection_actions.js'; + +class Smart_select_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.Helper = new Helper_class(); + this.POP = new Dialog_class(); + this.ctx = ctx; + this.name = 'smart_select'; + + // Store the current mask data + this.currentMask = null; + this.maskCanvas = null; + this.isProcessing = false; + this.selectionBounds = null; + + // Marching ants animation + this.marchingAntsOffset = 0; + + // Edge canvas for drawing the mask outline + this.edgeCanvas = null; + + // Quick-action panel shown after selection + this.selectionActions = new SelectionActions(this); + } + + load() { + var _this = this; + + // Mouse click event for selection + document.addEventListener('mousedown', function (e) { + _this.mousedown(e); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + if (config.TOOL.name != _this.name) return; + if (_this.Helper.is_input(e.target)) return; + + var code = e.keyCode; + + // Delete - delete selected area + if (code == 46 && _this.currentMask) { + e.preventDefault(); + _this.deleteSelection(); + } + // Escape - clear selection + if (code == 27 && _this.currentMask) { + e.preventDefault(); + _this.clearSelection(); + } + // Ctrl+C - copy to new layer + if (code == 67 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.copyToLayer(); + } + // Ctrl+X - cut to new layer + if (code == 88 && (e.ctrlKey || e.metaKey) && _this.currentMask) { + e.preventDefault(); + _this.cutToLayer(); + } + }); + + // Start marching ants animation + this.startMarchingAnts(); + } + + startMarchingAnts() { + var _this = this; + + // Animate every 100ms for smooth marching ants + setInterval(function() { + if (_this.currentMask) { + _this.marchingAntsOffset++; + if (_this.marchingAntsOffset > 16) { + _this.marchingAntsOffset = 0; + } + config.need_render = true; + } + }, 100); + } + + async mousedown(e) { + var mouse = this.get_mouse_info(e); + + if (config.TOOL.name != this.name) return; + if (mouse.click_valid == false) return; + if (this.isProcessing) { + alertify.warning('Processing... please wait'); + return; + } + + // Check if we have an image layer + if (config.layer.type != 'image') { + alertify.error('Please select an image layer first'); + return; + } + + // Get click coordinates relative to the image + var x = mouse.x - config.layer.x; + var y = mouse.y - config.layer.y; + + // Adjust for layer scaling + if (config.layer.width != config.layer.width_original) { + x = x * (config.layer.width_original / config.layer.width); + } + if (config.layer.height != config.layer.height_original) { + y = y * (config.layer.height_original / config.layer.height); + } + + // Make sure click is within image bounds + if (x < 0 || y < 0 || x > config.layer.width_original || y > config.layer.height_original) { + alertify.error('Click inside the image'); + return; + } + + // Check for Shift key - additive selection + var isAdditive = e.shiftKey; + + this.isProcessing = true; + alertify.message('AI is analyzing the image...'); + + try { + // Get image data as base64 + var imageData = this.getLayerImageData(); + + // Call SAM API + var result = await apiService.smartSelect(imageData, Math.round(x), Math.round(y)); + + // Apply the mask as selection (additive if Shift is held) + this.applyMask(result.mask, result.bbox, isAdditive); + + if (isAdditive && this.currentMask) { + alertify.success('Added to selection! Shift+Click to add more.'); + } else { + this._showActionPanel(); + } + + } catch (error) { + console.error('Smart select error:', error); + alertify.error('Selection failed: ' + error.message); + } finally { + this.isProcessing = false; + } + } + + /** + * Get the current layer's image data as base64 + */ + getLayerImageData() { + var canvas = document.createElement('canvas'); + var ctx = canvas.getContext('2d'); + + canvas.width = config.layer.width_original; + canvas.height = config.layer.height_original; + + // Draw the layer's image + ctx.drawImage(config.layer.link, 0, 0); + + // Return as base64 (remove data:image/png;base64, prefix) + return canvas.toDataURL('image/png').split(',')[1]; + } + + /** + * Apply the SAM mask as a selection + * @param {string} maskBase64 - Base64 encoded mask image + * @param {Object} bbox - Bounding box {x, y, width, height} + * @param {boolean} isAdditive - If true, add to existing selection + */ + applyMask(maskBase64, bbox, isAdditive) { + var _this = this; + + // Create mask image + var maskImage = new Image(); + maskImage.onload = function() { + // Create new mask canvas + var newMaskCanvas = document.createElement('canvas'); + newMaskCanvas.width = config.layer.width_original; + newMaskCanvas.height = config.layer.height_original; + var newMaskCtx = newMaskCanvas.getContext('2d'); + // Scale mask to match layer dimensions + newMaskCtx.drawImage(maskImage, 0, 0, newMaskCanvas.width, newMaskCanvas.height); + + // If additive and we have an existing mask, combine them + if (isAdditive && _this.maskCanvas) { + var combinedCanvas = document.createElement('canvas'); + combinedCanvas.width = config.layer.width_original; + combinedCanvas.height = config.layer.height_original; + var combinedCtx = combinedCanvas.getContext('2d'); + + // Draw existing mask + combinedCtx.drawImage(_this.maskCanvas, 0, 0); + + // Add new mask using 'lighter' composite to combine white areas + combinedCtx.globalCompositeOperation = 'lighter'; + combinedCtx.drawImage(newMaskCanvas, 0, 0); + + _this.maskCanvas = combinedCanvas; + } else { + _this.maskCanvas = newMaskCanvas; + } + + _this.currentMask = { + canvas: _this.maskCanvas, + bbox: bbox + }; + + // Store globally for AI inpaint tool to access + window.smartSelectMask = _this.currentMask; + + // Calculate selection bounds from mask + _this.calculateSelectionBounds(); + + // Extract contour path from the mask + _this.extractContourPath(); + + // Trigger re-render + config.need_render = true; + _this.Base_layers.render(); + }; + maskImage.src = 'data:image/png;base64,' + maskBase64; + } + + /** + * Calculate the bounding box of the selection from the mask + */ + calculateSelectionBounds() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + + // Find bounding box of selection + var minX = this.maskCanvas.width, minY = this.maskCanvas.height; + var maxX = 0, maxY = 0; + var hasSelection = false; + + for (var y = 0; y < this.maskCanvas.height; y++) { + for (var x = 0; x < this.maskCanvas.width; x++) { + var i = (y * this.maskCanvas.width + x) * 4; + if (imageData.data[i] > 128) { // White pixel in mask + hasSelection = true; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + } + + if (hasSelection && maxX > minX && maxY > minY) { + // Scale to current layer dimensions + var scaleX = config.layer.width / config.layer.width_original; + var scaleY = config.layer.height / config.layer.height_original; + + this.selectionBounds = { + x: config.layer.x + minX * scaleX, + y: config.layer.y + minY * scaleY, + width: (maxX - minX) * scaleX, + height: (maxY - minY) * scaleY, + // Store original coordinates too + origMinX: minX, + origMinY: minY, + origMaxX: maxX, + origMaxY: maxY + }; + } + } + + /** + * Extract contour points from the mask for drawing the outline + * Uses a simple edge detection approach + */ + extractContourPath() { + if (!this.maskCanvas) return; + + var maskCtx = this.maskCanvas.getContext('2d'); + var imageData = maskCtx.getImageData(0, 0, this.maskCanvas.width, this.maskCanvas.height); + var width = this.maskCanvas.width; + var height = this.maskCanvas.height; + var data = imageData.data; + + // Create edge canvas - pixels that are on the edge of the mask + this.edgeCanvas = document.createElement('canvas'); + this.edgeCanvas.width = width; + this.edgeCanvas.height = height; + var edgeCtx = this.edgeCanvas.getContext('2d'); + var edgeImageData = edgeCtx.createImageData(width, height); + var edgeData = edgeImageData.data; + + // Find edge pixels (mask pixels adjacent to non-mask pixels) + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var i = (y * width + x) * 4; + var isMask = data[i] > 128; + + if (isMask) { + // Check if any neighbor is NOT mask (edge pixel) + var isEdge = false; + + // Check 4-connected neighbors + if (x > 0 && data[i - 4] <= 128) isEdge = true; + if (x < width - 1 && data[i + 4] <= 128) isEdge = true; + if (y > 0 && data[i - width * 4] <= 128) isEdge = true; + if (y < height - 1 && data[i + width * 4] <= 128) isEdge = true; + + // Also check boundary + if (x == 0 || x == width - 1 || y == 0 || y == height - 1) isEdge = true; + + if (isEdge) { + edgeData[i] = 255; + edgeData[i + 1] = 255; + edgeData[i + 2] = 255; + edgeData[i + 3] = 255; + } + } + } + } + + edgeCtx.putImageData(edgeImageData, 0, 0); + } + + /** + * Render overlay - called by miniPaint's rendering system + * Shows the mask with marching ants outline + */ + render_overlay(ctx) { + if (!this.currentMask || !this.maskCanvas) return; + + ctx.save(); + + // Draw semi-transparent overlay on non-selected areas + var inverseCanvas = document.createElement('canvas'); + inverseCanvas.width = this.maskCanvas.width; + inverseCanvas.height = this.maskCanvas.height; + var inverseCtx = inverseCanvas.getContext('2d'); + + // Fill with semi-transparent black + inverseCtx.fillStyle = 'rgba(0, 0, 0, 0.4)'; + inverseCtx.fillRect(0, 0, inverseCanvas.width, inverseCanvas.height); + + // Cut out the selected area (so selected area is NOT darkened) + inverseCtx.globalCompositeOperation = 'destination-out'; + inverseCtx.drawImage(this.maskCanvas, 0, 0); + + // Draw the overlay on the main canvas + ctx.drawImage( + inverseCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + + // Draw marching ants border around the actual mask contour + if (this.edgeCanvas) { + // Create a canvas for marching ants effect + var antsCanvas = document.createElement('canvas'); + antsCanvas.width = this.maskCanvas.width; + antsCanvas.height = this.maskCanvas.height; + var antsCtx = antsCanvas.getContext('2d'); + + // Draw the edge + antsCtx.drawImage(this.edgeCanvas, 0, 0); + + // Apply marching ants color using composite + antsCtx.globalCompositeOperation = 'source-in'; + + // Alternate color based on animation offset + var color = ((Math.floor(this.marchingAntsOffset / 4) % 2) === 0) ? '#00ff00' : '#ffffff'; + antsCtx.fillStyle = color; + antsCtx.fillRect(0, 0, antsCanvas.width, antsCanvas.height); + + // Draw the marching ants outline + ctx.drawImage( + antsCanvas, + config.layer.x, config.layer.y, + config.layer.width, config.layer.height + ); + } + + ctx.restore(); + } + + /** + * Copy selected area to a new layer + * The result preserves the mask shape with transparency + */ + copyToLayer() { + var _this = this; + + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to copy'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + // Get the bounds of the selection + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + // Create canvas with just the selected pixels (masked) + var maskedCanvas = document.createElement('canvas'); + maskedCanvas.width = layer.width_original; + maskedCanvas.height = layer.height_original; + var maskedCtx = maskedCanvas.getContext('2d'); + + // Draw original image + maskedCtx.drawImage(layer.link, 0, 0); + + // Apply mask - keep only selected pixels (this creates the shape!) + maskedCtx.globalCompositeOperation = 'destination-in'; + maskedCtx.drawImage(this.maskCanvas, 0, 0); + + // Crop to selection bounds (still preserves transparency within the crop) + var cropWidth = bounds.origMaxX - bounds.origMinX + 1; + var cropHeight = bounds.origMaxY - bounds.origMinY + 1; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + // Copy only the selected region (transparency is preserved) + croppedCtx.drawImage( + maskedCanvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + // Calculate position for new layer + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + // Create new layer with the selection - use data as dataURL string + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: Math.round(cropWidth * scaleX), + height: Math.round(cropHeight * scaleY), + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: config.layer.name + ' (Selection)', + data: croppedCanvas.toDataURL('image/png') + }; + + app.State.do_action( + new app.Actions.Bundle_action('copy_selection_to_layer', 'Copy Selection to Layer', [ + new app.Actions.Insert_layer_action(params) + ]) + ); + + // Enable transparency so the user can see the mask shape + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + _this.Base_layers.render(); + } + + alertify.success('Selection copied to new layer! Switch to Select tool to move it.'); + } + + /** + * Cut selected area to a new layer (copy + delete from original) + * The result preserves the mask shape with transparency + */ + cutToLayer() { + var _this = this; + + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to cut'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + // Get the bounds of the selection + var bounds = this.selectionBounds; + if (!bounds || bounds.origMinX === undefined) { + alertify.error('Invalid selection bounds'); + return; + } + + // Create canvas with just the selected pixels (masked) + var maskedCanvas = document.createElement('canvas'); + maskedCanvas.width = layer.width_original; + maskedCanvas.height = layer.height_original; + var maskedCtx = maskedCanvas.getContext('2d'); + + // Draw original image + maskedCtx.drawImage(layer.link, 0, 0); + + // Apply mask - keep only selected pixels (this creates the shape!) + maskedCtx.globalCompositeOperation = 'destination-in'; + maskedCtx.drawImage(this.maskCanvas, 0, 0); + + // Crop to selection bounds (still preserves transparency within the crop) + var cropWidth = bounds.origMaxX - bounds.origMinX + 1; + var cropHeight = bounds.origMaxY - bounds.origMinY + 1; + + if (cropWidth <= 0 || cropHeight <= 0) { + alertify.error('Selection is too small'); + return; + } + + var croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropWidth; + croppedCanvas.height = cropHeight; + var croppedCtx = croppedCanvas.getContext('2d'); + + // Copy only the selected region (transparency is preserved) + croppedCtx.drawImage( + maskedCanvas, + bounds.origMinX, bounds.origMinY, cropWidth, cropHeight, + 0, 0, cropWidth, cropHeight + ); + + // Calculate position for new layer + var scaleX = layer.width / layer.width_original; + var scaleY = layer.height / layer.height_original; + + // Create params for new layer + var params = { + x: Math.round(layer.x + bounds.origMinX * scaleX), + y: Math.round(layer.y + bounds.origMinY * scaleY), + width: Math.round(cropWidth * scaleX), + height: Math.round(cropHeight * scaleY), + width_original: cropWidth, + height_original: cropHeight, + type: 'image', + name: config.layer.name + ' (Cut)', + data: croppedCanvas.toDataURL('image/png') + }; + + // Create canvas with hole where selection was + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + // Draw original image + holeCtx.drawImage(layer.link, 0, 0); + + // Cut out the mask area (creates transparent hole in original shape) + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + // Execute both actions - update original layer, then insert new layer + app.State.do_action( + new app.Actions.Bundle_action('cut_selection_to_layer', 'Cut Selection to Layer', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id), + new app.Actions.Insert_layer_action(params) + ]) + ); + + // Enable transparency so the user can see the mask shape + if (config.TRANSPARENCY == false) { + config.TRANSPARENCY = true; + _this.Base_layers.render(); + } + + // Clear the selection + this.clearSelection(); + + alertify.success('Selection cut to new layer! Switch to Select tool to move it.'); + } + + /** + * Delete the selected area from the image + */ + deleteSelection() { + if (!this.currentMask || !this.maskCanvas) { + alertify.error('No selection to delete'); + return; + } + + var layer = config.layer; + if (layer.type != 'image') { + alertify.error('Layer must be an image'); + return; + } + + // Create canvas with hole where selection was + var holeCanvas = document.createElement('canvas'); + holeCanvas.width = layer.width_original; + holeCanvas.height = layer.height_original; + var holeCtx = holeCanvas.getContext('2d'); + + // Draw original image + holeCtx.drawImage(layer.link, 0, 0); + + // Cut out the mask area + holeCtx.globalCompositeOperation = 'destination-out'; + holeCtx.drawImage(this.maskCanvas, 0, 0); + + app.State.do_action( + new app.Actions.Bundle_action('delete_selection', 'Delete Selection', [ + new app.Actions.Update_layer_image_action(holeCanvas, layer.id) + ]) + ); + + // Clear the selection + this.clearSelection(); + + alertify.success('Selection deleted!'); + } + + /** + * Show the quick-action panel for the current selection. + */ + _showActionPanel() { + var imageData = this.getLayerImageData(); + var maskData = this.maskCanvas + ? this.maskCanvas.toDataURL('image/png').split(',')[1] + : null; + if (maskData) { + this.selectionActions.show(imageData, maskData); + } + } + + /** + * Update the current layer canvas with a base64 result from a backend operation. + */ + updateLayerWithResult(base64) { + updateLayerWithResult(base64, this); + } + + /** + * Clear the current selection + */ + clearSelection() { + this.selectionActions.hide(); + this.currentMask = null; + this.maskCanvas = null; + this.edgeCanvas = null; + this.selectionBounds = null; + window.smartSelectMask = null; + config.need_render = true; + this.Base_layers.render(); + } + + on_leave() { + this.selectionActions.hide(); + return []; + } +} + +export default Smart_select_class; diff --git a/paintplus/frontend/src/js/tools/text.js b/paintplus/frontend/src/js/tools/text.js new file mode 100644 index 0000000..2c2b0aa --- /dev/null +++ b/paintplus/frontend/src/js/tools/text.js @@ -0,0 +1,2730 @@ +import app from './../app.js'; +import config from './../config.js'; +import zoomView from './../libs/zoomView.js'; +import Base_tools_class from './../core/base-tools.js'; +import Base_selection_class from './../core/base-selection.js'; +import Base_layers_class from './../core/base-layers.js'; +import GUI_tools_class from './../core/gui/gui-tools.js'; +import Helper_class from './../libs/helpers.js'; +import Dialog_class from './../libs/popup.js'; +import WebFont from 'webfontloader'; +import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js'; + +/** + * TODO + * - Add leading, superscript, subscript + * - Implement text direction (right to left, top to bottom, etc.); currently partial implementation + * - Allow search & add google fonts + * - Undo history + */ + +// Default text styling +// WARNING - changing this could break backwards compatibility! +// Defaults aren't saved in text layer in order to reduce data size and increase meta comparison performance. +export const metaDefaults = { + size: 40, + family: 'Arial', + kerning: 0, + leading: 0, + bold: false, + italic: false, + underline: false, + strikethrough: false, + fill_color: '#008800', + stroke_size: 0, + stroke_color: '#000000' +}; + +// Global map of font name to font metrics information. +const fontMetricsMap = new Map(); +const layerEditors = new WeakMap(); +const fontLoadPromiseMap = new Map(); +const fontLoadMap = new Map(); +fontLoadMap.set('Arial', true); +fontLoadMap.set('Courier', true); +fontLoadMap.set('Impact', true); +fontLoadMap.set('Helvetica', true); +fontLoadMap.set('Monospace', true); +fontLoadMap.set('Tahoma', true); +fontLoadMap.set('Times New Roman', true); +fontLoadMap.set('Verdana', true); + +function load_font_family({ family, variants }, successCallback) { + if (fontLoadMap.get(family) == null) { + fontLoadMap.set(family, false); + const loadPromise = new Promise((resolve, reject) => { + WebFont.load({ + google: { + families: [family + (variants ? ':' + variants.join(',') : '')] + }, + fontactive: (family) => { + fontLoadMap.set(family, true); + fontLoadPromiseMap.delete(family); + resolve(); + }, + fontinactive: (family) => { + alertify.error('Font ' + family + ' could not be loaded.'); + fontLoadPromiseMap.delete(family); + reject(); + } + }); + }); + fontLoadPromiseMap.set(family, loadPromise); + } + if (successCallback) { + const loadPromise = fontLoadPromiseMap.get(family); + if (loadPromise) { + loadPromise.then(successCallback); + } else if (fontLoadMap.get(family) == true) { + requestAnimationFrame(() => { + successCallback(); + }); + } + } +} + +/** + * The canvas's native font metrics implementation doesn't really give us enough information... + */ +const kerningTestCanvas = document.createElement('canvas'); +kerningTestCanvas.width = 10; +kerningTestCanvas.height = 10; +kerningTestCanvas.style = 'font-kerning: normal; text-rendering: optimizeLegibility;'; +const kerningTestCtx = kerningTestCanvas.getContext('2d'); +class Font_metrics_class { + constructor(family, size) { + this.family = family || (family = "Arial"); + this.size = parseInt(size) || (size = 12); + this.kerningMap = new Map(); + + // Preparing container + const line = document.createElement('div'); + const body = document.body; + line.style.position = 'absolute'; + line.style.whiteSpace = 'nowrap'; + line.style.font = size + 'px ' + family; + body.appendChild(line); + + // Now we can measure width and height of the letter + const text = '——————————'; // 10 symbols to be more accurate with width + line.innerHTML = text; + this.width = line.offsetWidth / text.length; + this.height = line.offsetHeight; + + // Now creating 1px sized item that will be aligned to baseline + // to calculate baseline shift + const baseline = document.createElement('span'); + baseline.style.display = 'inline-block'; + baseline.style.overflow = 'hidden'; + baseline.style.width = '1px'; + baseline.style.height = '1px'; + line.appendChild(baseline); + + // Baseline is important for positioning text on canvas + this.baseline = baseline.offsetTop + baseline.offsetHeight; + + document.body.removeChild(line); + } + + /** + * Attempts to determine the height of a letter via pixel comparison + * @param {string} letter - The letter to check + * @param {string} [baseline] - Baseline position override + */ + calculate_letter_bounds(letter, baseline) { + baseline = baseline || 'alphabetic' + kerningTestCanvas.width = this.width; + kerningTestCanvas.height = this.height; + kerningTestCtx.clearRect(0, 0, this.width, this.height); + kerningTestCtx.font = + ' ' + (this.size) + 'px' + + ' ' + this.family; + kerningTestCtx.textAlign = 'left'; + kerningTestCtx.textBaseline = baseline; + kerningTestCtx.fillStyle = '#000000'; + kerningTestCtx.fillText(letter, 0, baseline === 'alphabetic' ? this.baseline : 0); + const pixels = kerningTestCtx.getImageData(0, 0, this.width, this.height).data; + const pixelLength = pixels.length; + let start = 0; + let end = this.height; + for (let i = 0; i < pixelLength; i += 4) { + if (pixels[i + 3] !== 0) { + start = Math.floor(i / 4 / this.width); + break; + } + } + for (let i = pixelLength - 4; i >= 0; i -= 4) { + if (pixels[i + 3] !== 0) { + end = Math.floor(i / 4 / this.width); + break; + } + } + kerningTestCanvas.width = 10; + kerningTestCanvas.height = 10; + return { + top: start, + bottom: end, + height: end - start + } + } + + /** + * Calculate the kerning offset between two letters. + * @param {string} letters - a two character string of the two letters to determine font kerning from. Returns the kerning offset that should be used to draw the 2nd letter. + * @param {object} flags - font style, such as bold or italic + */ + get_kerning_offset(letters, flags = {}) { + let offset = this.kerningMap.get(letters); + if (offset == null) { + kerningTestCtx.font = + ' ' + (flags.italic ? 'italic' : '') + + ' ' + (flags.bold ? 'bold' : '') + + ' ' + (this.size) + 'px' + + ' ' + this.family; + offset = kerningTestCtx.measureText(letters).width - (kerningTestCtx.measureText(letters[0]).width + kerningTestCtx.measureText(letters[1]).width); + this.kerningMap.set(letters, offset); + } + return offset; + } +} + +/** + * This class's job is to store and modify the internal JSON format of a text layer. + */ +class Text_document_class { + constructor() { + this.lines = []; + this.on_change = null; + + // If user edits params while no selection, queue meta insertion for next type. + this.queuedMetaChanges = null; + } + + /** + * Returns the number of lines in the document. + */ + get_line_count() { + return this.lines.length; + } + + /** + * Returns the length of a given line + * @param {number} lineNumber - The number of the line to get the length of + */ + get_line_character_count(lineNumber) { + return this.get_line_text(lineNumber).length; + } + + /** + * Returns the text string at a given line (ignores formatting). + * @param {number} lineNumber - The number of the line to get the text from + */ + get_line_text(lineNumber) { + let lineText = ''; + for (let i = 0; i < this.lines[lineNumber].length; i++) { + lineText += this.lines[lineNumber][i].text; + } + return lineText; + } + + /** + * Returns the position of the end of the the word at the line/character provided + * @param {number} line - The reference line number (0 indexed) + * @param {number} character - The reference character position (0 indexed) + * @param {boolean} noJump - Dont jump to the next word if at the end of current one + */ + get_word_end_position(line, character, noJump) { + let newLine = line; + let newCharacter = character; + let fullText = this.get_line_text(newLine); + if (character === fullText.length && newLine < this.lines.length - 1) { + if (noJump) { + return { line, character }; + } + newLine += 1; + character = 0; + fullText = this.get_line_text(newLine); + } + const text = fullText.slice(character); + if (noJump && text[0] === ' ') { + return { line, character }; + } + for (let i = 1; i < text.length; i++) { + if (text[i] === ' ') { + newCharacter = character + i; + break; + } + } + if (newCharacter === character) { + newCharacter = fullText.length + 1; + } + return { + line: newLine, + character: newCharacter + } + } + + /** + * Returns the position of the start of the the word at the line/character provided + * @param {number} line - The reference line number (0 indexed) + * @param {number} character - The reference character position (0 indexed) + * @param {boolean} noJump - Dont jump to the next word if at the end of current one + */ + get_word_start_position(line, character, noJump) { + let newLine = line; + let newCharacter = character; + let isWrap = false; + if (character === 0 && newLine > 0) { + if (noJump) { + return { line, character }; + } + isWrap = true; + newLine -= 1; + } + const fullText = this.get_line_text(newLine); + if (isWrap) { + character = fullText.length; + } + const text = fullText.slice(0, character); + if (noJump && text[text.length - 1] === ' ') { + return { line, character }; + } + for (let i = -1; i >= -text.length; i--) { + if (text[i + text.length - 1] === ' ') { + newCharacter = character + i; + break; + } + } + if (newCharacter === character) { + newCharacter = 0; + } + return { + line: newLine, + character: newCharacter + } + } + + /** + * Determine if the metadata (formatting) of two text spans is the same, usually used to determine if the spans can be merged together. + */ + is_same_span_meta(meta1, meta2) { + const meta1Keys = Object.keys(meta1).sort(); + const meta2Keys = Object.keys(meta2).sort(); + if (meta1Keys.length !== meta2Keys.length) { + return false; + } + for (let i = 0; i < meta1Keys.length; i++) { + if (meta1Keys[i] !== meta2Keys[i]) { + return false; + } + const meta1Value = meta1[meta1Keys[i]]; + const meta2Value = meta2[meta2Keys[i]]; + if (JSON.stringify(meta1Value) !== JSON.stringify(meta2Value)) { + return false; + } + } + return true; + } + + /** + * Inserts a span with empty text in the document at the specified line and character position + * @param {number} line - The line number to insert at (0 indexed) + * @param {number} character - The character position to insert at (0 indexed) + * @param {object} meta - Metadata to associate with span + */ + insert_empty_span(line, character, meta) { + let insertedSpan = null; + const lineDef = this.lines[line]; + let newLine = []; + let spanStartCharacter = 0; + let wasInserted = false; + for (let span of lineDef) { + if (!wasInserted && character >= spanStartCharacter && character <= spanStartCharacter + span.text.length) { + let textBefore = span.text.slice(0, character - spanStartCharacter); + let textAfter = span.text.slice(character - spanStartCharacter); + if (textBefore.length > 0) { + newLine.push({ + text: textBefore, + meta: JSON.parse(JSON.stringify(span.meta)) + }); + } + const newMeta = JSON.parse(JSON.stringify(span.meta)); + for (let metaKey in meta) { + newMeta[metaKey] = meta[metaKey]; + } + insertedSpan = { + text: '', + meta: newMeta + }; + newLine.push(insertedSpan); + if (textAfter.length > 0) { + newLine.push({ + text: textAfter, + meta: JSON.parse(JSON.stringify(span.meta)) + }); + } + wasInserted = true; + } else { + newLine.push(span); + } + spanStartCharacter += span.text.length; + } + this.lines[line] = newLine; + return insertedSpan; + } + + /** + * Inserts a text string in the document at the specified line and character position + * @param {string} text - The text string to insert + * @param {number} line - The line number to insert at (0 indexed) + * @param {number} character - The character position to insert at (0 indexed) + */ + insert_text(text, line, character) { + + let insertedSpan; + if (this.queuedMetaChanges) { + insertedSpan = this.insert_empty_span(line, character, this.queuedMetaChanges); + this.queuedMetaChanges = null; + } + + const insertLine = this.lines[line]; + const textHasNewline = text.includes('\n'); + let characterCount = 0; + let modifyingSpan = null; + let previousSpans = []; + let nextSpans = []; + let newLine = line; + let newCharacter = character; + + // Insert text into span at specified line/character + for (let i = 0; i < insertLine.length; i++) { + const span = insertLine[i]; + const spanLength = span.text.length; + if (!modifyingSpan && (character > characterCount || character === 0) && character <= characterCount + spanLength) { + if (insertLine[i + 1] && insertLine[i + 1].text === '') { + modifyingSpan = insertLine[i + 1]; + } else { + modifyingSpan = span; + } + const textIdx = character - characterCount; + modifyingSpan.text = modifyingSpan.text.slice(0, textIdx) + text + modifyingSpan.text.slice(textIdx); + if (!textHasNewline) { + newCharacter = characterCount + textIdx + text.length; + break; + } + } else if (textHasNewline) { + if (modifyingSpan) { + nextSpans.push(span); + } else { + previousSpans.push(span); + } + } + characterCount += spanLength; + } + + // Create new lines if newline character was used + if (textHasNewline && modifyingSpan) { + const modifiedSpans = []; + const textLines = modifyingSpan.text.split('\n'); + for (let i = 0; i < textLines.length; i++) { + modifiedSpans.push({ + meta: JSON.parse(JSON.stringify(modifyingSpan.meta)), + text: textLines[i] + }); + } + this.lines[line] = [...previousSpans, modifiedSpans.shift()]; + for (let i = 0; i < modifiedSpans.length; i++) { + if (i === modifiedSpans.length - 1) { + if (!modifiedSpans[i].text && nextSpans.length > 0) { + this.lines.splice(line + i + 1, 0, nextSpans); + } else { + this.lines.splice(line + i + 1, 0, [modifiedSpans[i], ...nextSpans]); + } + newLine = line + i + 1; + newCharacter = text.length - 1 - text.lastIndexOf('\n'); + } else { + this.lines.splice(line + i + 1, 0, [modifiedSpans[i]]); + } + } + } + + // Notify change + if (this.on_change) { + this.on_change(this.lines); + } + + // Return end position + return { + line: newLine, + character: newCharacter + }; + } + + /** + * Deletes text withing the specified range + * @param {number} startLine - The starting line of the text range + * @param {number} startCharacter - The character position at the starting line of the text range + * @param {number} endLine - The ending line of the text range + * @param {number} endCharacter - The character position at the ending line of the text range + */ + delete_range(startLine, startCharacter, endLine, endCharacter) { + // Check bounds + startLine >= 0 || (startLine = 0); + startCharacter >= 0 || (startCharacter = 0); + endLine < this.lines.length || (endLine = this.lines.length - 1); + const endLineCharacterCount = this.get_line_character_count(endLine); + endCharacter <= endLineCharacterCount || ( + endCharacter = endLineCharacterCount + ); + + // Early return if there's nothing to delete + if (startLine === endLine && startCharacter === endCharacter) { + return { + line: startLine, + character: startCharacter + }; + } + + // Get spans in start line before range + const beforeSpans = []; + const afterSpans = []; + let characterCount = 0; + let startSpan = null; + let startSpanDeleteIndex = 0; + for (let i = 0; i < this.lines[startLine].length; i++) { + const span = this.lines[startLine][i]; + const spanLength = span.text.length; + if (!startSpan && (startCharacter > characterCount || startCharacter === 0) && startCharacter <= characterCount + spanLength) { + startSpan = span; + startSpanDeleteIndex = Math.max(0, startCharacter - characterCount); + break; + } + if (!startSpan) { + beforeSpans.push(span); + } + characterCount += spanLength; + } + + // Get spans in end line after range + characterCount = 0; + let endSpan = null; + let endSpanDeleteIndex = 0; + for (let i = 0; i < this.lines[endLine].length; i++) { + const span = this.lines[endLine][i]; + const spanLength = span.text.length; + if (!endSpan && (endCharacter > characterCount || endCharacter === 0) && endCharacter <= characterCount + spanLength) { + endSpan = span; + endSpanDeleteIndex = Math.max(0, endCharacter - characterCount); + } + else if (endSpan) { + afterSpans.push(span); + } + characterCount += spanLength; + } + + // Merge start and end lines + this.lines[startLine] = [...beforeSpans]; + if (startSpan === endSpan || this.is_same_span_meta(startSpan.meta, endSpan.meta)) { + const combinedSpans = { + meta: startSpan.meta, + text: startSpan.text.slice(0, startSpanDeleteIndex) + endSpan.text.slice(endSpanDeleteIndex) + }; + if (combinedSpans.text || (beforeSpans.length === 0 && afterSpans.length === 0)) { + this.lines[startLine].push(combinedSpans); + } + } else { + const middleSpans = []; + let isAddedStartSpan = false; + let isAddedEndSpan = false; + if (startSpan) { + startSpan.text = startSpan.text.slice(0, startSpanDeleteIndex); + if (startSpan.text) { + middleSpans.push(startSpan); + isAddedStartSpan = true; + } + } + if (endSpan) { + endSpan.text = endSpan.text.slice(endSpanDeleteIndex) + if (endSpan.text || middleSpans.length === 0) { + middleSpans.push(endSpan); + isAddedEndSpan = true; + } + } + if (isAddedStartSpan && !isAddedEndSpan) { + const afterSpan = afterSpans[0]; + if (afterSpan && this.is_same_span_meta(startSpan.meta, afterSpan.meta)) { + afterSpans.shift(); + startSpan.text += afterSpan.text; + } + } + else if (isAddedEndSpan && !isAddedStartSpan) { + const beforeSpan = beforeSpans[beforeSpans.length - 1]; + if (beforeSpan && this.is_same_span_meta(beforeSpan.meta, endSpan.meta)) { + beforeSpans.pop(); + beforeSpan.text += endSpan.text; + } + } + else if (middleSpans.length === 0) { + const beforeSpan = beforeSpans[beforeSpans.length - 1]; + const afterSpan = afterSpans[0]; + if (beforeSpan && afterSpan && this.is_same_span_meta(beforeSpan.meta, afterSpan.meta)) { + afterSpans.shift(); + beforeSpan.text += afterSpan.text; + } + } + this.lines[startLine] = this.lines[startLine].concat(middleSpans); + } + this.lines[startLine] = this.lines[startLine].concat(afterSpans); + + // Delete lines in-between range + this.lines.splice(startLine + 1, endLine - startLine); + + // Notify change + if (this.on_change) { + this.on_change(this.lines); + } + + // Return new position + return { + line: startLine, + character: startCharacter + }; + } + + /** + * Deletes a single character in front or behind the specified character position, handling deleting new lines, etc. + * @param {boolean} forward - True if deleting the next character, otherwise deletes the previous character + * @param {number} startLine - The line number to delete from + * @param {number} startCharacter - The character position to delete from + */ + delete_character(forward, startLine, startCharacter) { + let endLine = startLine; + let endCharacter = startCharacter; + + // Delete forwards + if (forward) { + // If there are characters after cursor on this line we remove one + if (startCharacter < this.get_line_character_count(startLine)) { + ++endCharacter; + } + // if there are Lines after this one we append it + else if (startLine < this.lines.length - 1) { + ++endLine; + endCharacter = 0; + } + } + // Delete backwards + else { + // If there are characters before the cursor on this line we remove one + if (startCharacter > 0) { + --startCharacter; + } + // if there are rows before we append current to previous one + else if (startLine > 0) { + --startLine; + startCharacter = this.get_line_character_count(startLine); + } + } + + return this.delete_range(startLine, startCharacter, endLine, endCharacter); + } + + /** + * Retrieves a metadata summary object for the specified range of text. + * @param {number} startLine - The starting line of the text range + * @param {number} startCharacter - The character position at the starting line of the text range + * @param {number} endLine - The ending line of the text range + * @param {number} endCharacter - The character position at the ending line of the text range + */ + get_meta_range(startLine, startCharacter, endLine, endCharacter) { + // Check bounds + startLine >= 0 || (startLine = 0); + startCharacter >= 0 || (startCharacter = 0); + endLine < this.lines.length || (endLine = this.lines.length - 1); + const endLineCharacterCount = this.get_line_character_count(endLine); + endCharacter <= endLineCharacterCount || ( + endCharacter = endLineCharacterCount + ); + const isEmpty = startLine === endLine && startCharacter === endCharacter; + + // Loop through all spans in range and collect meta values + const metaCollection = {}; + for (const metaKey in metaDefaults) { + metaCollection[metaKey] = []; + } + let isInsideRange = false; + for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) { + const line = this.lines[lineIndex]; + let spanStartCharacter = 0; + let startSpan = null; + let endSpan = null; + for (let spanIndex = 0; spanIndex < line.length; spanIndex++) { + const span = line[spanIndex]; + if (lineIndex === startLine) { + if ( + (!isEmpty && startCharacter >= spanStartCharacter && startCharacter < spanStartCharacter + span.text.length) || + (isEmpty && startCharacter > spanStartCharacter && startCharacter <= spanStartCharacter + span.text.length) || + (startCharacter === 0 && spanStartCharacter === 0) + ) { + isInsideRange = true; + startSpan = span; + } + } + if (lineIndex === endLine && isInsideRange) { + if ( + (!isEmpty && endCharacter <= spanStartCharacter + span.text.length) || + (isEmpty && endCharacter < spanStartCharacter + span.text.length) + ) { + endSpan = span; + isInsideRange = false; + } + } + if (isInsideRange || startSpan === span || (!isEmpty && endSpan === span)) { + for (const metaKey in metaCollection) { + let metaValue = span.meta[metaKey]; + if (metaValue == null) { + metaValue = metaDefaults[metaKey]; + } + if (!metaCollection[metaKey].includes(metaValue)) { + metaCollection[metaKey].push(metaValue); + } + } + } + spanStartCharacter += span.text.length; + } + } + + // Fill in default values for undefined meta keys + for (const metaKey in metaDefaults) { + if (metaCollection[metaKey].length === 0) { + metaCollection[metaKey] = [metaDefaults[metaKey]]; + } + } + return metaCollection; + } + + /** + * Sets styling metadata for the specified range of text. + * @param {number} startLine - The starting line of the text range + * @param {number} startCharacter - The character position at the starting line of the text range + * @param {number} endLine - The ending line of the text range + * @param {number} endCharacter - The character position at the ending line of the text range + * @param {object} meta - The meta to set + */ + set_meta_range(startLine, startCharacter, endLine, endCharacter, meta) { + // Check bounds + startLine >= 0 || (startLine = 0); + startCharacter >= 0 || (startCharacter = 0); + endLine < this.lines.length || (endLine = this.lines.length - 1); + const endLineCharacterCount = this.get_line_character_count(endLine); + endCharacter <= endLineCharacterCount || ( + endCharacter = endLineCharacterCount + ); + + // Set meta of spans in selection + let isInsideRange = false; + for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) { + const line = this.lines[lineIndex]; + let newLine = []; + let spanStartCharacter = 0; + for (let span of line) { + const spanText = span.text; + const spanLength = spanText.length; + if (lineIndex === startLine) { + if (startCharacter <= spanStartCharacter) { + isInsideRange = true; + } + } + if (lineIndex === endLine) { + if (endCharacter < spanStartCharacter + spanLength) { + isInsideRange = false; + } + } + // Selection start splits the span it's inside of + let choppedStartCharacters = 0; + if (startCharacter > spanStartCharacter && startCharacter < spanStartCharacter + spanLength && lineIndex === startLine) { + choppedStartCharacters = startCharacter - spanStartCharacter; + newLine.push({ + text: span.text.slice(0, startCharacter - spanStartCharacter), + meta: JSON.parse(JSON.stringify(span.meta)) + }); + span.text = span.text.slice(startCharacter - spanStartCharacter); + isInsideRange = true; + } + newLine.push(span); + // Selection end splits the span it's inside of + if (endCharacter > spanStartCharacter && endCharacter < spanStartCharacter + spanLength && lineIndex === endLine) { + newLine.push({ + text: span.text.slice(endCharacter - spanStartCharacter - choppedStartCharacters), + meta: JSON.parse(JSON.stringify(span.meta)) + }); + span.text = span.text.slice(0, endCharacter - spanStartCharacter - choppedStartCharacters); + isInsideRange = true; + } + // Add meta to span + if (isInsideRange) { + for (const metaKey in meta) { + span.meta[metaKey] = meta[metaKey]; + } + } + spanStartCharacter += spanLength; + } + this.lines[lineIndex] = newLine; + } + + this.normalize(startLine, endLine); + + // Notify change + if (this.on_change) { + this.on_change(this.lines); + } + } + + /** + * Merges sibling spans that have the same metadata, and removes empty spans. + * @param {number} startLine - The starting line of the text range + * @param {number} endLine - The ending line of the text range + */ + normalize(startLine, endLine) { + for (let lineIndex = startLine; lineIndex <= endLine; lineIndex++) { + const line = this.lines[lineIndex]; + let spanIndex = 0; + for (spanIndex = 0; spanIndex < line.length; spanIndex++) { + const span1 = line[spanIndex]; + const span2 = line[spanIndex + 1]; + if (span1 && span2 && this.is_same_span_meta(span1.meta, span2.meta)) { + line[spanIndex] = { + text: span1.text + span2.text, + meta: span1.meta + }; + line.splice(spanIndex + 1, 1); + spanIndex--; + continue; + } + if (span1.text === '' && line.length > 1) { + line.splice(spanIndex, 1); + spanIndex--; + continue; + } + } + } + } + +} + + +/** + * This class represents a single selection range in a text editor's document. + */ +class Text_selection_class { + constructor(/* Text_editor_class */ editor) { + this.editor = editor; + this.isVisible = false; + this.isCursorVisible = false; + this.isActiveSideEnd = true; + this.isBlinkVisible = true; + this.blinkInterval = 500; + + this.start = { + line: 0, + character: 0 + }; + + this.end = { + line: 0, + character: 0 + }; + + this.set_position(0, 0); + } + + /** + * Returns if the current text selection contains no characters + * @returns {boolean} + */ + is_empty() { + return this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character) === 0; + } + + /** + * Determines the relative position of two line/character sets. + * @param {number} line1 + * @param {number} character1 + * @param {number} line2 + * @param {number} character2 + * @returns {number} -1 if line1/character1 is less than line2/character2, 1 if greater, and 0 if equal + */ + compare_position(line1, character1, line2, character2) { + if (line1 < line2) { + return -1; + } else if (line1 > line2) { + return 1; + } else { + if (character1 < character2) { + return -1; + } else if (character1 > character2) { + return 1; + } else { + return 0; + } + } + } + + /** + * Sets the head position of the selection to the specified line/character, optionally extends to selection to that position. + * @param {number} line - The line number to set the selection to + * @param {number} character - The character index to set the selection to + * @param {boolean} [keepSelection] - If true, extends the current selection to the specified position. If false or undefined, sets an empty selection at that position. + */ + set_position(line, character, keepSelection) { + if (line == null) { + line = this.end.line; + } + if (character == null) { + character = this.end.character; + } + + // Check lower bounds + line >= 0 || (line = 0); + character >= 0 || (character = 0); + + // Check upper bounds + const lineCount = this.editor.document.get_line_count(); + line < lineCount || (line = lineCount - 1); + const lineCharacterCount = this.editor.document.get_line_character_count(line); + character <= lineCharacterCount || (character = lineCharacterCount); + + // Add to selection + if (keepSelection) { + const positionCompare = this.compare_position( + line, + character, + this.start.line, + this.start.character + ); + + // Determine whether we should make the start side of the range active, selection moving left or up. + if (positionCompare === -1 && (this.is_empty() || line < this.start.line)) { + this.isActiveSideEnd = false; + } + + // Assign new value to the side that is active + if (this.isActiveSideEnd) { + this.end.line = line; + this.end.character = character; + } else { + this.start.line = line; + this.start.character = character; + } + + // Making sure that end is greater than start and swap if necessary + if (this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character) > 0) { + this.isActiveSideEnd = !this.isActiveSideEnd; + const temp = { + line: this.start.line, + character: this.start.character + } + this.start.line = this.end.line; + this.start.character = this.end.character; + this.end.line = temp.line; + this.end.character = temp.character; + } + } + // Empty cursor move + else { + this.isActiveSideEnd = true; + this.start.line = this.end.line = line; + this.start.character = this.end.character = character; + } + + // Reset cursor blink + this.isBlinkVisible = true; + if (this.isVisible) { + this.start_blinking(); + } + } + + /** + * Retrieves the position of the head of the selection (could be the start or end of the selection based on previous operations) + * @returns {object} - { line, character } + */ + get_position() { + if (this.isActiveSideEnd) { + return { + character: this.end.character, + line: this.end.line + }; + } else { + return { + character: this.start.character, + line: this.start.line + }; + } + } + + /** + * Gets the plain text value in the current selection range. + * @returns {string} + */ + get_text() { + const positionCompare = this.compare_position(this.start.line, this.start.character, this.end.line, this.end.character); + const firstLine = positionCompare === 1 ? this.end.line : this.start.line; + const lastLine = positionCompare === 1 ? this.start.line : this.end.line; + const firstCharacter = positionCompare === 1 ? this.end.character : this.start.character; + const lastCharacter = positionCompare === 1 ? this.start.character : this.end.character; + let textLines = []; + for (let i = firstLine; i <= lastLine; i++) { + if (i === firstLine && i === lastLine) { + textLines.push(this.editor.document.get_line_text(i).slice(firstCharacter, lastCharacter)); + } else if (i === firstLine) { + textLines.push(this.editor.document.get_line_text(i).slice(firstCharacter)); + } else if (i === lastLine) { + textLines.push(this.editor.document.get_line_text(i).slice(0, lastCharacter)); + } else { + textLines.push(this.editor.document.get_line_text(i)); + } + } + return textLines.join('\n'); + } + + /** + * Sets the visibility of the selection in the editor. + * @param {boolean} isVisible + */ + set_visible(isVisible) { + if (this.isVisible != isVisible) { + this.isVisible = isVisible; + } + } + + /** + * Sets the visibility of the selection cursor in the editor. + * @param {boolean} isVisible + */ + set_cursor_visible(isVisible) { + if (this.isCursorVisible != isVisible) { + this.isCursorVisible = isVisible; + if (this.isCursorVisible) { + this.isBlinkVisible = true; + this.start_blinking(); + } else { + this.stop_blinking(); + } + } + } + + /** + * Starts the selection cursor blinking. + */ + start_blinking() { + clearInterval(this.blinkIntervalHandle); + this.blinkIntervalHandle = setInterval(this.blink.bind(this), this.blinkInterval); + } + + /** + * Stops the selection cursor blinking. + */ + stop_blinking() { + clearInterval(this.blinkIntervalHandle); + } + + /** + * Toggles the visibility of the selection cursor. + */ + blink() { + this.isBlinkVisible = !this.isBlinkVisible; + const firstLine = Math.min(this.start.line, this.end.line); + const lastLine = Math.max(this.start.line, this.end.line); + /* + this.editor.render({ + lineStart: firstLine, + lineEnd: lastLine + }); + */ + // this.Base_layers.render(); + } + + /** + * Moves the cursor to a previous line. + * @param {number} length - The number of lines to move + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_line_previous(length, keepSelection) { + length = length == null ? 1 : length; + const position = this.get_position(); + this.set_position(position.line - length, null, keepSelection); + } + + /** + * Moves the cursor to a next line. + * @param {number} length - The number of lines to move + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_line_next(length, keepSelection) { + length = length == null ? 1 : length; + const position = this.get_position(); + this.set_position(position.line + length, null, keepSelection); + } + + /** + * Moves to the start of the current line. + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_line_start(keepSelection) { + const position = this.get_position(); + this.set_position(position.line, 0, keepSelection); + } + + /** + * Moves to the end of the current line. + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_line_end(keepSelection) { + const position = this.get_position(); + this.set_position(position.line, this.editor.document.get_line_character_count(position.line), keepSelection); + } + + /** + * Moves the cursor to a character behind in the document, handles line wrapping. + * @param {number} length - The number of characters to move + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_character_previous(length, keepSelection) { + length = length == null ? 1 : length; + const position = this.get_position(); + if (position.character - length < 0) { + if (position.line > 0) { + this.set_position(position.line - 1, this.editor.document.get_line_character_count(position.line - 1), keepSelection); + } + } else { + this.set_position(position.line, position.character - length, keepSelection); + } + } + + /** + * Moves the cursor to a character ahead in the document, handles line wrapping. + * @param {number} length - The number of characters to move + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_character_next(length, keepSelection) { + length = length == null ? 1 : length; + const position = this.get_position(); + const characterCount = this.editor.document.get_line_character_count(position.line); + if (position.character + length > characterCount) { + if (position.line + 1 < this.editor.document.lines.length) { + this.set_position(position.line + 1, 0, keepSelection); + } + } else { + this.set_position(position.line, position.character + length, keepSelection); + } + } + + /** + * Moves the cursor to the beginning of the current word or previous word, handles line wrapping. + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_word_previous(keepSelection) { + const position = this.get_position(); + const newPosition = this.editor.document.get_word_start_position(position.line, position.character); + this.set_position(newPosition.line, newPosition.character, keepSelection); + } + + /** + * Moves the cursor to the end of the current word or next word, handles line wrapping. + * @param {boolean} keepSelection - Whether to move to an empty selection or extend the current selection + */ + move_word_next(keepSelection) { + const position = this.get_position(); + const newPosition = this.editor.document.get_word_end_position(position.line, position.character); + this.set_position(newPosition.line, newPosition.character, keepSelection); + } +} + + +/** + * This class handles rendering a text layer and editing it based on keyboard/mouse/touch controls + */ +class Text_editor_class { + constructor(options) { + options = options || {}; + + this.editingCtx = document.getElementById('canvas_minipaint').getContext("2d"); + this.hasValueChanged = false; + + // Text boundary and offsets are precomputed before drawn + this.lineRenderInfo = null; + this.lastCalculatedZoom = 0; + this.lastCalculatedLayerWidth = 0; + this.lastCalculatedLayerHeight = 0; + this.textBoundaryWidth = 0; + this.textBoundaryHeight = 0; + + // Styling options during render + this.selectionBackgroundColor = options.selectionBackgroundColor || '#1C79C4'; + this.selectionTextColor = options.selectionTextColor || '#FFFFFF'; + + // Offset from top/left of layer for cursor visibility + this.drawOffsetTop = options.paddingVertical != null ? options.paddingVertical : 6; + this.drawOffsetLeft = options.paddingHorizontal != null ? options.paddingHorizontal : 10; + + // Tracking internal state for keyboard/mouse/touch control + this.shiftPressed = false; + this.ctrlPressed = false; + this.isMouseSelectionActive = false; + this.mouseSelectionStartX = 0; + this.mouseSelectionStartY = 0; + this.mouseSelectionStartLine = null; + this.mouseSelectionStartCharacter = null; + this.mouseSelectionMoveX = null; + this.mouseSelectionMoveY = null; + this.mouseSelectionEdgeScrollInterval = null; + this.focused = false; + + // Text document for this editor + this.document = new Text_document_class(); + this.document.lines = [[{ text: '', meta: {} }]]; + this.wrappedLines = [[]]; + + // Text selection for this editor + this.selection = new Text_selection_class(this); + + // The layer associated with this editor (so data can be updated) + this.layer = null; + this.document.on_change = () => { + this.layer.data = this.document.lines; + }; + } + + /** + * Sets the lines of the document (from layer data) + * @param {array} lines + */ + set_lines(lines) { + this.document.lines = lines || [[{ text: '', meta: {} }]]; + } + + /** + * Returns the text string at a given line wrap (ignores formatting). + * @param {object} wrap - The wrap definition + */ + get_wrap_text(wrap) { + let wrapText = ''; + for (let i = 0; i < wrap.spans.length; i++) { + wrapText += wrap.spans[i].text; + } + return wrapText; + } + + /** + * Calculates font metrics for the given span and returns it. Caches by default. + * @param {object} span - The span to calculate metrics for + * @param {boolean} noCache - Skip caching if the metrics is expected to change in the future (e.g. font family not loaded yet.) + */ + get_span_font_metrics(span, noCache) { + const fontSize = (span.meta.size || metaDefaults.size); + const fontName = (span.meta.family || metaDefaults.family); + let fontMetrics = fontMetricsMap.get(fontName + '_' + fontSize); + if (!fontMetrics) { + fontMetrics = new Font_metrics_class(fontName, fontSize); + if (!noCache) { + fontMetricsMap.set(fontName + '_' + fontSize, fontMetrics); + } + } + return fontMetrics; + } + + /** + * Returns the complete text of the document. + */ + get_complete_text() { + let completeText = ''; + for (let line of this.document.lines) { + for (let span of line) { + completeText += span.text; + } + if (this.document.lines.indexOf(line) !== this.document.lines.length - 1) { + completeText += '\n'; + } + } + return completeText; + } + + replace_entire_IME_text(beforeTempText, newText) { + const cursorPosition = this.selection.get_position(); + let allText = beforeTempText; + let lines = allText.split('\n'); + let currentLineText = lines[cursorPosition.line]; + let beforeText = currentLineText.substring(0, cursorPosition.character); + let afterText = currentLineText.substring(cursorPosition.character); + let updatedLineText = beforeText + newText + afterText; + lines[cursorPosition.line] = updatedLineText; + + const newLines = lines.map(lineText => { + return [{ text: lineText, meta: {} }]; + }); + this.set_lines(newLines); + this.hasValueChanged = true; + } + + set_IME_position(newText) { + const cursorPosition = this.selection.get_position(); + let newTextLines = newText.split('\n'); + let newCursorLine = cursorPosition.line + newTextLines.length - 1; + let newCursorCharacter = newText.length; + this.selection.set_position(newCursorLine, newCursorCharacter + cursorPosition.character); + this.hasValueChanged = true; + } + + + + insert_text_at_current_position(text) { + if (!this.selection.is_empty()) { + this.delete_character_at_current_position(); + } + const position = this.selection.get_position(); + const newPosition = this.document.insert_text(text, position.line, position.character); + this.selection.set_position(newPosition.line, newPosition.character); + this.hasValueChanged = true; + } + + delete_character_at_current_position(forward) { + let newPosition; + if (this.selection.is_empty()) { + const position = this.selection.get_position(); + newPosition = this.document.delete_character(forward, position.line, position.character); + } else { + newPosition = this.document.delete_range( + this.selection.start.line, + this.selection.start.character, + this.selection.end.line, + this.selection.end.character + ); + } + this.selection.set_position(newPosition.line, newPosition.character); + this.hasValueChanged = true; + } + + delete_selection() { + let newPosition = this.document.delete_range( + this.selection.start.line, + this.selection.start.character, + this.selection.end.line, + this.selection.end.character + ); + this.selection.set_position(newPosition.line, newPosition.character); + this.hasValueChanged = true; + } + + trigger_cursor_start(layer, layerX, layerY) { + this.isMouseSelectionActive = true; + this.mouseSelectionStartX = layerX; + this.mouseSelectionStartY = layerY; + const cursorStart = this.get_cursor_position_from_absolute_position(layer, layerX, layerY); + this.mouseSelectionStartLine = cursorStart.line; + this.mouseSelectionStartCharacter = cursorStart.character; + this.selection.set_position(cursorStart.line, cursorStart.character, false); + } + + trigger_cursor_move(layer, layerX, layerY) { + const isInsideCanvas = true; // layerX > 0 && layerY > 0 && layerX < this.lastCalculatedLayerWidth && layerY < this.lastCalculatedLayerHeight; + if (this.isMouseSelectionActive && isInsideCanvas) { + this.mouseSelectionMoveX = layerX; + this.mouseSelectionMoveY = layerY; + const cursorEnd = this.get_cursor_position_from_absolute_position(layer, layerX, layerY); + this.selection.set_position(this.mouseSelectionStartLine, this.mouseSelectionStartCharacter, false); + this.selection.set_position(cursorEnd.line, cursorEnd.character, true); + } + } + + trigger_cursor_end() { + this.isMouseSelectionActive = false; + this.mouseSelectionMoveX = null; + this.mouseSelectionMoveY = null; + } + + get_cursor_position_from_absolute_position(layer, x, y) { + let line = -1; + let character = -1; + + if (this.lineRenderInfo) { + const textDirection = layer.params.text_direction; + const wrapDirection = layer.params.wrap_direction; + const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection); + const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection); + + let characterPosition = isHorizontalTextDirection ? x : y; + let wrapPosition = isHorizontalTextDirection ? y : x; + + const wrapSizes = this.lineRenderInfo.wrapSizes; + let wrapRelativeIndex = -1; + + let globalWrapIndex = 0; + for (let [lineIndex, lineInfo] of this.lineRenderInfo.lines.entries()) { + wrapRelativeIndex = 0; + for (let wrap of lineInfo.wraps) { + if (wrapPosition < wrapSizes[globalWrapIndex].offset + wrapSizes[globalWrapIndex].size) { + line = lineIndex; + break; + } + globalWrapIndex++; + wrapRelativeIndex++; + } + if (line > -1) { + break; + } + } + if (line === -1) { + line = this.lineRenderInfo.lines.length - 1; + wrapRelativeIndex = -1; + } + const wraps = this.lineRenderInfo.lines[line].wraps; + if (wrapRelativeIndex === -1) { + wrapRelativeIndex = wraps.length - 1; + } + let previousWrapCharacterCount = 0; + for (let w = 0; w < wrapRelativeIndex; w++) { + previousWrapCharacterCount += this.get_wrap_text(wraps[w]).length; + } + const characterCount = this.get_wrap_text(wraps[wrapRelativeIndex]).length; + const characterOffsets = wraps[wrapRelativeIndex].characterOffsets; + for (let characterNumber = 0; characterNumber < characterCount; characterNumber++) { + const leftPosition = characterOffsets[characterNumber]; + const rightPosition = characterOffsets[characterNumber + 1]; + if (characterPosition <= leftPosition + ((rightPosition - leftPosition) * 0.5)) { + character = previousWrapCharacterCount + characterNumber; + break; + } + if (characterNumber === characterCount - 1 && character === -1) { + character = previousWrapCharacterCount + characterCount; + } + } + if (character === -1) { + character = this.document.get_line_character_count(line); + } + } + return { line, character }; + } + + calculate_text_placement(ctx, layer) { + const boundary = layer.params.boundary; + const textDirection = layer.params.text_direction; + const wrapDirection = layer.params.wrap_direction; + const halign = layer.params.halign; + const valign = layer.params.valign; + const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection); + const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection); + + let totalTextDirectionSize = 0; + let totalWrapDirectionSize = 0; + let textDirectionMaxSize = isHorizontalTextDirection ? layer.width : layer.height; + + // Determine new lines based on text wrapping, if applicable + let lineRenderInfo = { + wrapSizes: [], + lines: [] + }; + for (let line of this.document.lines) { + let wrapAccumulativeSize = 0; + let wrapCharacterOffsets = [0]; + let lineWraps = []; + let currentWrapSpans = [...line]; + let s = 0; + let fontMetrics = null; + let character = null; + let nextCharacter = null; + let fontKerning = 0; + for (s = 0; s < currentWrapSpans.length; s++) { + const span = currentWrapSpans[s]; + const kerning = span.meta.kerning || metaDefaults.kerning; + const family = span.meta.family || metaDefaults.family; + const size = span.meta.size || metaDefaults.size; + fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family)); + if (isHorizontalTextDirection) { + ctx.font = + ' ' + (span.meta.italic ? 'italic' : '') + + ' ' + (span.meta.bold ? 'bold' : '') + + ' ' + size + 'px' + + ' ' + family; + } + for (let c = 0; c < span.text.length; c++) { + character = span.text[c]; + if (layer.params.kerning === 'metrics') { + nextCharacter = span.text[c + 1]; + if (!nextCharacter && c === span.text.length - 1 && currentWrapSpans[s + 1]) { + const nextSpan = currentWrapSpans[s + 1]; + if (family === (nextSpan.meta.family || metaDefaults.family) && size === (nextSpan.meta.size || metaDefaults.size)) { + nextCharacter = nextSpan.text[0]; + } + } + fontKerning = isHorizontalTextDirection && nextCharacter ? fontMetrics.get_kerning_offset(character + nextCharacter) : 0; + } + const characterSize = isHorizontalTextDirection ? ctx.measureText(character).width : fontMetrics.height; + wrapAccumulativeSize += characterSize + fontKerning + kerning; + if (boundary !== 'dynamic' && wrapAccumulativeSize > textDirectionMaxSize && ![' ', '-'].includes(character)) { + // Find last span with space + let dividerPosition = -1; + let bs = s; + for (; bs >= 0; bs--) { + const backwardsSpan = currentWrapSpans[bs]; + const backwardsSpanText = (bs === s) ? backwardsSpan.text.substring(0, c) : backwardsSpan.text; + dividerPosition = backwardsSpanText.lastIndexOf(' '); + const dashPosition = backwardsSpanText.lastIndexOf('-'); + if (dashPosition > dividerPosition) { + dividerPosition = dashPosition; + } + if (dividerPosition > -1) { + break; + } + } + let beforeSpans = []; + let afterSpans = []; + // Found a previous span on the current line wrap that contains a space, split the line + if (dividerPosition > -1) { + beforeSpans = currentWrapSpans.slice(0, bs); + afterSpans = currentWrapSpans.slice(bs + 1); + const beforeText = currentWrapSpans[bs].text.substring(0, dividerPosition + 1); + const afterText = currentWrapSpans[bs].text.substring(dividerPosition + 1); + if (beforeText.length > 0) { + beforeSpans.push({ + text: beforeText, + meta: currentWrapSpans[bs].meta + }); + } + if (afterText.length > 0) { + afterSpans.unshift({ + text: afterText, + meta: currentWrapSpans[bs].meta + }); + } + } + // For word split only, break out. + else if (layer.params.wrap === 'word') { + wrapCharacterOffsets.push(wrapAccumulativeSize); + break; + } + // Otherwise, split the word + else { + if (s === 0 && c === 0) { + c++; + wrapCharacterOffsets.push(wrapAccumulativeSize); + } + beforeSpans = currentWrapSpans.slice(0, s); + afterSpans = currentWrapSpans.slice(s + 1); + const beforeText = currentWrapSpans[s].text.substring(0, c); + const afterText = currentWrapSpans[s].text.substring(c); + if (beforeText.length > 0) { + beforeSpans.push({ + text: beforeText, + meta: currentWrapSpans[s].meta + }); + } + if (afterText.length > 0) { + afterSpans.unshift({ + text: afterText, + meta: currentWrapSpans[s].meta + }); + } + } + let largestOffset = wrapCharacterOffsets[wrapCharacterOffsets.length-1]; + if (largestOffset > totalTextDirectionSize) { + totalTextDirectionSize = largestOffset; + } + const newWrap = { + characterOffsets: wrapCharacterOffsets, + spans: beforeSpans + }; + newWrap.characterOffsets = newWrap.characterOffsets.slice(0, this.get_wrap_text(newWrap).length + 1); + lineWraps.push(newWrap); + currentWrapSpans = afterSpans; + wrapAccumulativeSize = 0; + wrapCharacterOffsets = [0]; + s = -1; + break; + } else { + wrapCharacterOffsets.push(wrapAccumulativeSize); + } + } + if (s === -1) { + continue; + } + } + if (currentWrapSpans.length > 0) { + let largestOffset = wrapCharacterOffsets[wrapCharacterOffsets.length-1]; + if (largestOffset > totalTextDirectionSize) { + totalTextDirectionSize = largestOffset; + } + lineWraps.push({ + characterOffsets: wrapCharacterOffsets, + spans: currentWrapSpans + }); + } + lineRenderInfo.lines.push({ + firstWrapIndex: 0, + wraps: lineWraps + }); + } + + // Adjust offsets for alignment along the text direction + if ((isHorizontalTextDirection && halign !== 'left') || (!isHorizontalTextDirection && valign !== 'top')) { + const maxTextDirectionSize = boundary === 'dynamic' ? totalTextDirectionSize : (isHorizontalTextDirection ? layer.width : layer.height); + for (let line of lineRenderInfo.lines) { + for (let wrap of line.wraps) { + const isCentered = (isHorizontalTextDirection && halign == 'center') || (!isHorizontalTextDirection && valign === 'middle'); + const lastSpan = wrap.spans[wrap.spans.length - 1]; + const wrapSize = wrap.characterOffsets[wrap.characterOffsets.length - 1 - (lastSpan.text[lastSpan.text.length - 1] === ' ' ? 1 : 0)]; + const startOffset = (isCentered ? maxTextDirectionSize / 2 : maxTextDirectionSize) - (isCentered ? wrapSize / 2 : wrapSize); + if (startOffset > 0) { + for (let oi = 0; oi < wrap.characterOffsets.length; oi++) { + wrap.characterOffsets[oi] += startOffset; + } + } + } + } + } + + // Determine the size of each line (e.g. line height if horizontal typing direction) + let wrapSizeAccumulator = 0; + let wrapCounter = 0; + for (let line of lineRenderInfo.lines) { + line.firstWrapIndex = wrapCounter; + for (let wrap of line.wraps) { + let ascenderSize = 0; + let descenderSize = 0; + for (let span of wrap.spans) { + let fontMetrics; + const family = span.meta.family || metaDefaults.family; + const leading = span.meta.leading != null ? span.meta.leading : metaDefaults.leading; + if (isHorizontalTextDirection) { + fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family)); + } else { + ctx.font = + ' ' + (span.meta.italic ? 'italic' : '') + + ' ' + (span.meta.bold ? 'bold' : '') + + ' ' + (span.meta.size || metaDefaults.size) + 'px' + + ' ' + family; + } + let spanAscenderSize = isHorizontalTextDirection ? fontMetrics.baseline : ctx.measureText(character).width; + let spanDescenderSize = isHorizontalTextDirection ? Math.abs(fontMetrics.baseline - fontMetrics.height) : ctx.measureText(character).width; + if (leading) { + spanAscenderSize += leading; + if (spanAscenderSize < 0) { + spanDescenderSize += spanAscenderSize; + spanAscenderSize = 0; + if (spanDescenderSize < 0) { + spanDescenderSize = 0; + } + } + } + if (spanAscenderSize > ascenderSize) { + ascenderSize = spanAscenderSize; + } + if (spanDescenderSize > descenderSize) { + descenderSize = spanDescenderSize; + } + } + let lineSize = ascenderSize + descenderSize; + lineRenderInfo.wrapSizes.push({ size: lineSize, offset: wrapSizeAccumulator, baseline: ascenderSize }); + wrapSizeAccumulator += lineSize; + wrapCounter++; + } + } + totalWrapDirectionSize = wrapSizeAccumulator; + + this.lastCalculatedLayerWidth = layer.width; + this.lastCalculatedLayerHeight = layer.height; + this.textBoundaryWidth = Math.max(1, Math.round(isHorizontalTextDirection ? totalTextDirectionSize : totalWrapDirectionSize)); + this.textBoundaryHeight = Math.max(1, Math.round(isHorizontalTextDirection ? totalWrapDirectionSize : totalTextDirectionSize)); + this.lineRenderInfo = lineRenderInfo; + } + + render(ctx, layer) { + if (config.need_render_changed_params || this.hasValueChanged || layer.width != this.lastCalculatedLayerWidth || layer.height != this.lastCalculatedLayerHeight || !this.textBoundaryWidth || !this.textBoundaryHeight) { + this.calculate_text_placement(ctx, layer); + } + + if (!this.lineRenderInfo) return; + + try { + + let options = options || {}; + let isSelectionEmpty = this.selection.is_empty(); + + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + + const boundary = layer.params.boundary; + let drawOffsetTop = layer.y + 1; + let drawOffsetLeft = layer.x + 1; + const textDirection = layer.params.text_direction; + const wrapDirection = layer.params.wrap_direction; + const isHorizontalTextDirection = ['ltr', 'rtl'].includes(textDirection); + const isNegativeTextDirection = ['rtl', 'btt'].includes(textDirection); + + const wrapSizes = this.lineRenderInfo.wrapSizes; + let lineIndex = 0; + let wrapIndex = 0; + const cursorLine = this.selection.isActiveSideEnd ? this.selection.end.line : this.selection.start.line; + const cursorCharacter = this.selection.isActiveSideEnd ? this.selection.end.character : this.selection.start.character; + if(layer.rotate){ + const alpha = (layer.rotate * Math.PI) / 180; + ctx.save(); + // Move the canvas to the center before rotating + ctx.translate(layer.x + layer.width / 2, layer.y + layer.height / 2); + ctx.rotate(alpha); + // Move it back after it + ctx.translate(-layer.x - layer.width / 2, -layer.y - layer.height / 2); + + } + for (let line of this.lineRenderInfo.lines) { + let lineLetterCount = 0; + for (let [localWrapIndex, wrap] of line.wraps.entries()) { + let cursorStartX = null; + let cursorStartY = null; + let cursorSize = null; + let characterIndex = 0; + const characterOffsets = wrap.characterOffsets; + for (let [spanIndex, span] of wrap.spans.entries()) { + const kerning = span.meta.kerning != null ? span.meta.kerning : metaDefaults.kerning; + const bold = span.meta.bold != null ? span.meta.bold : metaDefaults.bold; + const italic = span.meta.italic != null ? span.meta.italic : metaDefaults.italic; + const underline = span.meta.underline != null ? span.meta.underline : metaDefaults.underline; + const strikethrough = span.meta.strikethrough != null ? span.meta.strikethrough : metaDefaults.strikethrough; + const family = span.meta.family || metaDefaults.family; + + if (fontLoadMap.get(family) !== true) { + const variants = config.user_fonts[family] ? config.user_fonts[family].variants : undefined; + load_font_family({ family, variants }, () => { + this.hasValueChanged = true; + this.Base_layers.render(); + }); + } + + let fontMetrics; + if (underline || strikethrough) { + fontMetrics = this.get_span_font_metrics(span, !fontLoadMap.get(family)); + } + + // Set styles for drawing + ctx.font = + ' ' + (italic ? 'italic' : '') + + ' ' + (bold ? 'bold' : '') + + ' ' + Math.round(span.meta.size || metaDefaults.size) + 'px' + + ' ' + family; + const fill_color = span.meta.fill_color || metaDefaults.fill_color; + let fillStyle; + if (fill_color.startsWith('#')) { + fillStyle = fill_color; + } + const stroke_size = ((span.meta.stroke_size != null) ? span.meta.stroke_size : metaDefaults.stroke_size); + let strokeStyle; + if (stroke_size) { + const stroke_color = span.meta.stroke_color || metaDefaults.stroke_color; + if (stroke_color.startsWith('#')) { + strokeStyle = stroke_color; + } + ctx.lineWidth = stroke_size; + } else { + ctx.lineWidth = 0; + } + + + + // Loop through each letter in each span and draw it + for (let c = 0; c < span.text.length; c++) { + const letter = span.text.charAt(c); + const lineStart = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset); + const letterWidth = characterOffsets[characterIndex + 1] - characterOffsets[characterIndex]; + const letterHeight = Math.round(wrapSizes[wrapIndex].size); + const textDirectionOffset = drawOffsetLeft + characterOffsets[characterIndex]; + const wrapDirectionOffset = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset + wrapSizes[wrapIndex].baseline); + const letterDrawX = isHorizontalTextDirection ? textDirectionOffset + kerning : wrapDirectionOffset; + const letterDrawY = isHorizontalTextDirection ? wrapDirectionOffset : textDirectionOffset + kerning; + let isLetterSelected = false; + if (this.selection.isVisible) { + if (!isSelectionEmpty) { + isLetterSelected = ( + ( + this.selection.start.line === lineIndex && + this.selection.start.character <= lineLetterCount && + (this.selection.end.line > lineIndex || this.selection.end.character > lineLetterCount) + ) || + ( + this.selection.end.line === lineIndex && + this.selection.end.character > lineLetterCount && + (this.selection.start.line < lineIndex || this.selection.start.character <= lineLetterCount) + ) || + ( + this.selection.start.line < lineIndex && + this.selection.end.line > lineIndex + ) + ); + } + if (cursorLine === lineIndex) { + if (cursorCharacter === lineLetterCount) { + cursorStartX = (isHorizontalTextDirection ? textDirectionOffset : lineStart) - 0.5; + cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset) - 0.5; + cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth; + } + else if (cursorCharacter === lineLetterCount + 1 && localWrapIndex === line.wraps.length - 1 && spanIndex === wrap.spans.length - 1 && c === span.text.length - 1) { + cursorStartX = (isHorizontalTextDirection ? textDirectionOffset + letterWidth : lineStart) - 0.5; + cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset + letterHeight) - 0.5; + cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth; + } + } + } + if (isLetterSelected && this.editingCtx === ctx) { + const letterStartX = isHorizontalTextDirection ? textDirectionOffset : lineStart; + const letterStartY = isHorizontalTextDirection ? lineStart : textDirectionOffset; + const letterSizeX = isHorizontalTextDirection ? letterWidth : letterHeight; + const letterSizeY = isHorizontalTextDirection ? letterHeight : letterWidth; + ctx.fillStyle = this.selectionBackgroundColor + '22'; + ctx.fillRect(letterStartX, letterStartY, letterSizeX, letterSizeY); + ctx.strokeStyle = this.selectionBackgroundColor; + ctx.lineWidth = 0.75; + ctx.strokeRect(letterStartX, letterStartY, letterSizeX, letterSizeY); + ctx.lineWidth = stroke_size; + } + ctx.fillStyle = fillStyle; + ctx.strokeStyle = strokeStyle; + ctx.fillText(letter, letterDrawX, letterDrawY); + if (stroke_size) { + ctx.lineWidth = stroke_size; + ctx.strokeText(letter, letterDrawX, letterDrawY); + } + if (strikethrough) { + ctx.fillStyle = fillStyle; + ctx.lineWidth = Math.max(1, fontMetrics.height / 20); + ctx.fillRect(letterDrawX - 0.25 - kerning, letterDrawY - (fontMetrics.height * .28), letterWidth + 0.5, ctx.lineWidth); + } + if (underline) { + ctx.fillStyle = fillStyle; + ctx.lineWidth = Math.max(1, fontMetrics.height / 20); + ctx.fillRect(letterDrawX - 0.25 - kerning, letterDrawY + (ctx.lineWidth), letterWidth + 0.5, ctx.lineWidth); + } + characterIndex++; + lineLetterCount++; + } + + + + if (span.text.length === 0) { + if (cursorLine === lineIndex && cursorCharacter === lineLetterCount) { + const lineStart = Math.round(drawOffsetTop + wrapSizes[wrapIndex].offset); + const textDirectionOffset = drawOffsetLeft + characterOffsets[0] + (lineIndex === 0 ? (boundary === 'dynamic' ? 5 : 2) : 0); + const letterWidth = 3; + const letterHeight = Math.round(wrapSizes[wrapIndex].size); + cursorStartX = (isHorizontalTextDirection ? textDirectionOffset : lineStart) - 0.5; + cursorStartY = (isHorizontalTextDirection ? lineStart : textDirectionOffset) - 0.5; + cursorSize = isHorizontalTextDirection ? letterHeight : letterWidth; + } + } + } + + // Draw cursor + if (this.selection.isCursorVisible /*&& this.selection.isBlinkVisible*/ && cursorStartX && this.editingCtx == ctx) { + ctx.lineCap = 'butt'; + ctx.strokeStyle = '#55555577'; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.moveTo(cursorStartX, cursorStartY + 1); + ctx.lineTo(cursorStartX, cursorStartY + cursorSize - 1); + if (cursorSize > 14) { + ctx.moveTo(cursorStartX - 3, cursorStartY + 2); + ctx.lineTo(cursorStartX + 3, cursorStartY + 2); + ctx.moveTo(cursorStartX - 3, cursorStartY + cursorSize - 2); + ctx.lineTo(cursorStartX + 3, cursorStartY + cursorSize - 2); + } + ctx.stroke(); + ctx.strokeStyle = '#ffffffff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(cursorStartX, cursorStartY + 2); + ctx.lineTo(cursorStartX, cursorStartY + cursorSize - 2); + if (cursorSize > 14) { + ctx.moveTo(cursorStartX - 2, cursorStartY + 2); + ctx.lineTo(cursorStartX + 2, cursorStartY + 2); + ctx.moveTo(cursorStartX - 2, cursorStartY + cursorSize - 2); + ctx.lineTo(cursorStartX + 2, cursorStartY + cursorSize - 2); + } + ctx.stroke(); + } + wrapIndex++; + } + lineIndex++; + } + if(layer.rotate){ + ctx.restore(); + } + } catch (error) { + console.warn(error); + } + + this.hasValueChanged = false; + } +} + +class Google_fonts_search_class { + constructor() { + this.POP = new Dialog_class(); + this.GUI_tools = new GUI_tools_class(); + this.popup = null; + this.fontsPerPage = 8; + this.dialogContentNode = null; + this.fontListNode = null; + this.fontList = []; + this.fontListFiltered = []; + this.selectedFonts = {}; + this.searchTimeoutHandle = null; + } + + render_font_list(page) { + page = page || 1; + const pageCount = Math.ceil(this.fontListFiltered.length / 8); + const startIndex = (page - 1) * this.fontsPerPage; + let html = '
    '; + for (let i = startIndex; i < startIndex + this.fontsPerPage; i++) { + const font = this.fontListFiltered[i]; + if (!font) break; + const isSelected = !!this.selectedFonts[font.family]; + load_font_family({ family: font.family, variants: font.variants }); + html += ` +
    + + +
    + `; + } + html += ` +
    + + + `; + this.fontListNode.innerHTML = html; + + // Attempt to remove vertical scroll by decreasing page size. + if (this.fontsPerPage > 3 && this.dialogContentNode.scrollHeight > this.dialogContentNode.clientHeight) { + this.fontsPerPage--; + this.render_font_list(page); + return; + } + + // Handle checkbox + this.fontListNode.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + checkbox.addEventListener('change', (e) => { + if (checkbox.checked) { + this.selectedFonts[checkbox.value] = this.fontListFiltered + .slice(startIndex, startIndex + this.fontsPerPage) + .filter((font) => { return font.family === checkbox.value; })[0]; + } else { + delete this.selectedFonts[checkbox.value]; + } + }); + }); + + // Handle pagination + this.fontListNode.querySelector('.pagination').addEventListener('click', (e) => { + const page = parseInt(e.target.getAttribute('data-page'), 10); + this.render_font_list(page); + }); + } + + show() { + this.POP.show({ + title: 'Search for Font', + params: [ + { name: "query", title: "Search:", value: '', prevent_submission: true } + ], + on_load: (params, popup) => { + this.popup = popup; + var node = document.createElement("div"); + this.dialogContentNode = popup.el.querySelector('.dialog_content'); + this.dialogContentNode.appendChild(node); + this.fontListNode = node; + + const queryInput = popup.el.querySelector('#pop_data_query'); + queryInput.addEventListener('input', (e) => { + const query = (e.target.value || '').toLowerCase(); + if (!query) { + this.fontListFiltered = this.fontList; + this.render_font_list(); + } else { + clearTimeout(this.searchTimeoutHandle); + this.searchTimeoutHandle = setTimeout(() => { + this.fontListFiltered = []; + for (let i = 0; i < this.fontList.length; i++) { + const fontFamily = this.fontList[i].family.toLowerCase(); + if (fontFamily.includes(query)) { + this.fontListFiltered.push(this.fontList[i]); + } + } + this.render_font_list(); + }, 350); + } + }); + + const apiKey = config.google_webfonts_key; + $.getJSON(`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`, (data) => { + this.fontList = data.items; + this.fontListFiltered = data.items; + this.render_font_list(); + }).fail(function () { + alertify.error('Error loading the list of fonts from Google.'); + }); + }, + on_finish: () => { + this.popup = null; + this.POP = null; + if (Object.keys(this.selectedFonts).length > 0) { + let firstFont = null; + for (let font in this.selectedFonts) { + if (!firstFont) { + firstFont = font; + } + config.user_fonts[font] = this.selectedFonts[font]; + } + app.GUI.GUI_tools.action_data().attributes.font.value = firstFont; + app.GUI.GUI_tools.show_action_attributes(); + try { + const changeEvent = new Event('change'); + document.querySelector('#action_attributes select#font').dispatchEvent(changeEvent); + } catch (error) { + console.warn('Application markup may have changed, ', error); + } + } + } + }); + } +} + + +class Text_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.GUI_tools = new GUI_tools_class(); + this.Helper = new Helper_class(); + this.ctx = ctx; + this.name = 'text'; + this.layer = {}; + this.creating = false; + this.selecting = false; + this.resizing = false; + this.focused = false; + this.focusedValue = null; + this.mousedownX = 0; + this.mousedownY = 0; + this.mousedownBounds = {}; + this.is_fonts_loaded = false; + if (ctx) { + this.selection = { + x: null, + y: null, + width: null, + height: null, + }; + var sel_config = { + enable_background: false, + enable_borders: true, + enable_controls: true, + enable_rotation: true, + enable_move: false, + data_function: () => { + return this.selection; + }, + }; + this.Base_selection = new Base_selection_class(ctx, sel_config, this.name); + + // Need a textarea in order to listen for keyboard inputs in an accessible, multi-platform independent way + this.textarea = document.createElement('textarea'); + this.textarea.id = 'text_tool_keyboard_input'; + this.textarea.setAttribute('autocorrect', 'off'); + this.textarea.setAttribute('autocapitalize', 'off'); + this.textarea.setAttribute('autocomplete', 'off'); + this.textarea.setAttribute('spellcheck', 'false'); + this.textarea.style = `position: absolute; top: 0; left: 0; padding: 0; width: 1px; height: 1px; background: transparent; border: none; outline: none; color: transparent; opacity: 0.01; pointer-events: none;`; + document.body.appendChild(this.textarea); + + this.textarea.addEventListener('focus', () => { + this.focused = true; + let editor = this.get_editor(this.layer); + if (editor) { + this.focusedValue = JSON.stringify(editor.document.lines); + } + }, true); + + this.textarea.addEventListener('blur', () => { + this.focused = false; + let editor = this.get_editor(this.layer); + if (editor) { + let value = JSON.stringify(editor.document.lines); + if (this.focusedValue !== value) { + this.layer.data = JSON.parse(this.focusedValue); + app.State.do_action( + new app.Actions.Update_layer_action(this.layer.id, { data: JSON.parse(value) }) + ); + } + } + this.focusedValue = null; + this.Base_layers.render(); + }, true); + + let isComposing = false; + let beforeImeText = ""; + this.textarea.addEventListener('compositionstart', () => { + beforeImeText = ""; + isComposing = true; + if (config.layer) { + const editor = this.get_editor(config.layer); + beforeImeText = editor.get_complete_text(); + } + }); + + this.textarea.addEventListener('compositionend', (e) => { + const editor = this.get_editor(config.layer); + editor.set_IME_position(e.target.value); + beforeImeText = ""; + isComposing = false; + e.target.value = ''; + }); + + this.textarea.addEventListener('input', (e) => { + if(isComposing){ + const editor = this.get_editor(config.layer); + editor.replace_entire_IME_text(beforeImeText, e.target.value); + this.Base_layers.render(); + this.extend_fixed_bounds(config.layer, editor); + } + else if (config.layer) { + const editor = this.get_editor(config.layer); + editor.insert_text_at_current_position(e.target.value); + e.target.value = ''; + this.Base_layers.render(); + this.extend_fixed_bounds(config.layer, editor); + } + }, true); + + this.textarea.addEventListener('keydown', (e) => { + if (config.layer) { + let handled = true; + const editor = this.get_editor(config.layer); + switch (e.key) { + case 'Backspace': + editor.delete_character_at_current_position(false); + break; + case 'Delete': + editor.delete_character_at_current_position(true); + break; + case 'Home': + editor.selection.move_line_start(e.shiftKey); + break; + case 'End': + editor.selection.move_line_end(e.shiftKey); + break; + case 'Left': case 'ArrowLeft': + if (!e.shiftKey && !editor.selection.is_empty()) { + editor.selection.isActiveSideEnd = false; + editor.selection.move_character_previous(0, false); + } else if (e.ctrlKey) { + editor.selection.move_word_previous(e.shiftKey); + } else { + editor.selection.move_character_previous(1, e.shiftKey); + } + break; + case 'Right': case 'ArrowRight': + if (!e.shiftKey && !editor.selection.is_empty()) { + editor.selection.isActiveSideEnd = true; + editor.selection.move_character_next(0, false); + } else if (e.ctrlKey) { + editor.selection.move_word_next(e.shiftKey); + } else { + editor.selection.move_character_next(1, e.shiftKey); + } + break; + case 'Up': case 'ArrowUp': + editor.selection.move_line_previous(1, e.shiftKey); + break; + case 'Down': case 'ArrowDown': + editor.selection.move_line_next(1, e.shiftKey); + break; + case 'a': + if (e.ctrlKey) { + editor.selection.set_position(0, 0); + const lastLine = editor.document.lines.length - 1; + editor.selection.set_position(lastLine, editor.document.get_line_character_count(lastLine), true); + break; + } + case 'b': + if (e.ctrlKey) { + e.preventDefault(); + document.querySelector('#action_attributes #bold').click(); + break; + } + case 'c': + if (e.ctrlKey) { + e.preventDefault(); + this.textarea.value = editor.selection.get_text(); + this.textarea.select(); + this.textarea.setSelectionRange(0, 99999); + document.execCommand('copy'); + this.textarea.value = ''; + break; + } + case 'i': + if (e.ctrlKey) { + e.preventDefault(); + document.querySelector('#action_attributes #italic').click(); + break; + } + case 'u': + if (e.ctrlKey) { + e.preventDefault(); + document.querySelector('#action_attributes #underline').click(); + break; + } + case 'x': + if (e.ctrlKey) { + e.preventDefault(); + this.textarea.value = editor.selection.get_text(); + this.textarea.select(); + this.textarea.setSelectionRange(0, 99999); + document.execCommand('copy'); + this.textarea.value = ''; + editor.delete_selection(); + break; + } + default: + handled = false; + } + if (handled) { + this.update_tool_attributes(config.layer, editor); + this.Base_layers.render(); + } + this.extend_fixed_bounds(config.layer, editor); + return !handled; + } + }, true); + } + } + + dragStart(event) { + if (config.TOOL.name != this.name) + return; + this.mousedown(event); + } + + dragMove(event) { + if (config.TOOL.name != this.name) + return; + this.mousemove(event); + } + + dragEnd(event) { + if (config.TOOL.name != this.name) + return; + this.mouseup(event); + } + + load() { + // Mouse events + document.addEventListener('mousedown', (event) => { + this.dragStart(event); + }); + document.addEventListener('mousemove', (event) => { + this.dragMove(event); + }); + document.addEventListener('mouseup', (event) => { + this.dragEnd(event); + }); + document.addEventListener('dblclick', (event) => { + this.doubleClick(event); + }); + + // Touch events + document.addEventListener('touchstart', (event) => { + this.dragStart(event); + }); + document.addEventListener('touchmove', (event) => { + this.dragMove(event); + }); + document.addEventListener('touchend', (event) => { + this.dragEnd(event); + }); + } + + mousedown(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) + return; + + this.creating = false; + this.selecting = false; + this.resizing = false; + + this.mousedownX = mouse.x; + this.mousedownY = mouse.y; + this.mousedownBounds = { + x: config.layer.x, + y: config.layer.y, + width: config.layer.width, + height: config.layer.height, + boundary: config.layer.params.boundary + }; + + if (this.Base_selection.mouse_lock !== null) { + this.resizing = true; + return; + } + + const existingLayer = this.get_text_layer_at_mouse(e); + if (existingLayer) { + this.selecting = true; + this.layer = existingLayer; + const editor = this.get_editor(this.layer); + editor.trigger_cursor_start(this.layer, -1 + mouse.x - this.layer.x, mouse.y - this.layer.y); + app.State.do_action( + new app.Actions.Bundle_action('select_text_layer', 'Select Text Layer', [ + new app.Actions.Select_layer_action(existingLayer.id), + new app.Actions.Set_selection_action(this.layer.x, this.layer.y, this.layer.width, this.layer.height) + ]) + ); + } + else { + // Create a new text layer + this.creating = true; + const layer = { + type: this.name, + params: { + boundary: 'dynamic', + kerning: 'metrics', + text_direction: 'ltr', + wrap_direction: 'ttb', + halign: 'left', + valign: 'top', + wrap: 'letter' + }, + render_function: [this.name, 'render'], + x: mouse.x, + y: mouse.y, + rotate: 0, + is_vector: true, + }; + app.State.do_action( + new app.Actions.Bundle_action('new_text_layer', 'New Text Layer', [ + new app.Actions.Insert_layer_action(layer), + new app.Actions.Set_selection_action(mouse.x, mouse.y, 0, 0) + ]) + ); + this.layer = config.layer; + } + } + + mousemove(e) { + var mouse = this.get_mouse_info(e); + if (mouse.is_drag == false) + return; + if (mouse.click_valid == false) { + return; + } + + if (this.resizing) { + config.layer.x = this.selection.x; + config.layer.y = this.selection.y; + config.layer.width = this.selection.width; + config.layer.height = this.selection.height; + if (config.layer.params.boundary === 'dynamic') { + config.layer.params.boundary = 'box'; + } + } + else if (this.creating) { + const width = Math.abs(mouse.x - this.mousedownX); + const height = Math.abs(mouse.y - this.mousedownY); + + //more data + if (config.layer.params.boundary === 'dynamic') { + config.layer.params.boundary = 'box'; + } + config.layer.x = Math.min(mouse.x, this.mousedownX); + config.layer.y = Math.min(mouse.y, this.mousedownY); + config.layer.width = width; + config.layer.height = height; + } else { + this.get_editor(this.layer).trigger_cursor_move(this.layer, -1 + mouse.x - this.layer.x, mouse.y - this.layer.y); + } + this.Base_layers.render(); + } + + mouseup(e) { + var mouse = this.get_mouse_info(e); + if (mouse.click_valid == false) { + return; + } + const editor = this.get_editor(this.layer); + + if (this.resizing) { + config.layer.x = this.mousedownBounds.x; + config.layer.y = this.mousedownBounds.y; + config.layer.width = this.mousedownBounds.width; + config.layer.height = this.mousedownBounds.height; + const new_params = JSON.parse(JSON.stringify(config.layer.params)); + new_params.boundary = config.layer.params.boundary; + config.layer.params.boundary = this.mousedownBounds.boundary; + app.State.do_action( + new app.Actions.Bundle_action('resize_text_layer', 'Resize Text Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: this.selection.x, + y: this.selection.y, + width: this.selection.width, + height: this.selection.height, + params: new_params + }), + new app.Actions.Set_selection_action(this.selection.x, this.selection.y, this.selection.width, this.selection.height) + ]) + ); + } + else if (this.creating) { + let width = Math.abs(mouse.x - this.mousedownX); + let height = Math.abs(mouse.y - this.mousedownY); + + if (width == 0 && height == 0) { + // Same coordinates - let render figure out dynamic width + width = 1; + height = 1; + } + app.State.do_action( + new app.Actions.Bundle_action('resize_text_layer', 'Resize Text Layer', [ + new app.Actions.Update_layer_action(config.layer.id, { + x: Math.min(mouse.x, this.mousedownX), + y: Math.min(mouse.y, this.mousedownY), + width, + height + }) + ]), + { merge_with_history: 'new_text_layer' } + ); + this.textarea.focus(); + } + else if (this.selecting) { + editor.trigger_cursor_end(); + this.textarea.focus(); + + if (editor.selection.is_empty() && editor.document.queuedMetaChanges) { + let meta = {}; + const existingMeta = editor.document.get_meta_range(editor.selection.start.line, editor.selection.start.character, editor.selection.end.line, editor.selection.end.character); + for (let metaKey in existingMeta) { + meta[metaKey] = editor.document.queuedMetaChanges[metaKey] != null ? editor.document.queuedMetaChanges[metaKey] : existingMeta[metaKey][0]; + } + } else { + editor.document.queuedMetaChanges = null; + this.update_tool_attributes(this.layer, editor); + } + } + + // Resize layer based on text boundaries. + this.extend_fixed_bounds(this.layer, editor); + this.Base_layers.render(); + + // Center layer on mouse if not click & drag + if (this.creating && config.layer.params.boundary === 'dynamic') { + requestAnimationFrame(() => { + app.State.do_action( + new app.Actions.Update_layer_action(config.layer.id, { + x: config.layer.x - config.layer.width / 2, + y: config.layer.y - config.layer.height / 2 + }), + { merge_with_history: 'new_text_layer' } + ); + }); + } + + this.resizing = false; + this.selecting = false; + this.creating = false; + } + + + doubleClick(event) { + if (document.activeElement === this.textarea) { + const editor = this.get_editor(this.layer); + if (editor.selection.is_empty()) { + const position = editor.selection.get_position(); + const wordStart = editor.document.get_word_start_position(position.line, position.character, true); + const wordEnd = editor.document.get_word_end_position(position.line, position.character, true); + editor.selection.set_position(wordStart.line, wordStart.character); + editor.selection.set_position(wordEnd.line, wordEnd.character, true); + this.update_tool_attributes(this.layer, editor); + } + } + } + + on_params_update(param) { + const editor = this.get_editor(config.layer); + const value = param.value; + const meta = {}; + let returnValue = undefined; + switch (param.key) { + case 'font': + if (value.includes('...')) { + returnValue = { + new_values: { + font: '' + } + }; + new Google_fonts_search_class().show(); + } + else if (value) meta.family = value; + break; + case 'size': + if (value) meta.size = value; + break; + case 'bold': + meta.bold = value; + break; + case 'italic': + meta.italic = value; + break; + case 'underline': + meta.underline = value; + break; + case 'strikethrough': + meta.strikethrough = value; + break; + case 'fill': + if (value) meta.fill_color = value; + break; + case 'stroke': + if (value) meta.stroke_color = value; + break; + case 'stroke_size': + if (!isNaN(value)) meta.stroke_size = value; + break; + case 'kerning': + if (!isNaN(value)) meta.kerning = value; + break; + case 'leading': + if (!isNaN(value)) meta.leading = value; + break; + } + if (editor.selection.is_empty()) { + if (!editor.document.queuedMetaChanges) { + editor.document.queuedMetaChanges = {}; + } + for (let metaKey in meta) { + editor.document.queuedMetaChanges[metaKey] = meta[metaKey]; + } + } else { + editor.document.queuedMetaChanges = null; + let oldData = JSON.parse(JSON.stringify(editor.document.lines)); + editor.document.set_meta_range(editor.selection.start.line, editor.selection.start.character, editor.selection.end.line, editor.selection.end.character, meta); + editor.hasValueChanged = true; + this.layer.data = oldData; + app.State.do_action( + new app.Actions.Update_layer_action(this.layer.id, { data: JSON.parse(JSON.stringify(editor.document.lines)) }) + ); + this.Base_layers.render(); + } + return returnValue; + } + + update_tool_attributes(layer, editor) { + if (layer && layer.params) { + const meta = editor.document.get_meta_range(editor.selection.start.line, editor.selection.start.character, editor.selection.end.line, editor.selection.end.character); + const toolAttributes = this.GUI_tools.action_data().attributes; + toolAttributes.font.value = meta.family.length === 1 ? meta.family[0] : ''; + toolAttributes.size = meta.size.length === 1 ? meta.size[0] : parseFloat(null); + toolAttributes.bold.value = meta.bold.includes(false) ? false : true; + toolAttributes.italic.value = meta.italic.includes(false) ? false : true; + toolAttributes.underline.value = meta.underline.includes(false) ? false : true; + toolAttributes.strikethrough.value = meta.strikethrough.includes(false) ? false : true; + toolAttributes.fill = meta.fill_color.length === 1 ? meta.fill_color[0] : '#000000'; + toolAttributes.stroke = meta.stroke_color.length === 1 ? meta.stroke_color[0] : '#000000'; + toolAttributes.stroke_size.value = meta.stroke_size.length === 1 ? meta.stroke_size[0] : parseFloat(null); + toolAttributes.kerning.value = meta.kerning.length === 1 ? meta.kerning[0] : parseFloat(null); + toolAttributes.leading.value = meta.leading.length === 1 ? meta.leading[0] : parseFloat(null); + this.GUI_tools.show_action_attributes(); + } + } + + resize_to_dynamic_bounds(layer, editor) { + if (layer && layer.params && layer.params.boundary === 'dynamic') { + let new_width = Math.max(9, editor.textBoundaryWidth + 1); + let new_height = Math.max(9, editor.textBoundaryHeight + 1); + config.layer.width = new_width; + config.layer.height = new_height; + } + } + + extend_fixed_bounds(layer, editor) { + if (layer && layer.params && layer.params.boundary !== 'dynamic') { + const isHorizontalTextDirection = ['ltr', 'rtl'].includes(layer.params.textDirection); + let new_width = layer.width; + let new_height = layer.height; + if (isHorizontalTextDirection) { + new_width = Math.max(editor.textBoundaryWidth + 1, new_width); + } else { + new_height = Math.max(editor.textBoundaryHeight + 1, new_height); + } + config.layer.width = new_width; + config.layer.height = new_height; + } + } + + render(ctx, layer) { + if (layer.width == 0 && layer.height == 0) + return; + var params = layer.params; + + const isActiveLayerAndTextTool = layer === config.layer && config.TOOL.name === 'text'; + const editor = this.get_editor(layer); + editor.selection.set_visible(isActiveLayerAndTextTool); + editor.selection.set_cursor_visible(isActiveLayerAndTextTool && (this.selecting || this.focused)); + editor.render(ctx, layer); + if (layer === config.layer) { + this.resize_to_dynamic_bounds(layer, editor); + } + if (!this.resizing && isActiveLayerAndTextTool) { + this.selection.x = layer.x; + this.selection.y = layer.y; + this.selection.width = layer.width; + this.selection.height = layer.height; + this.selection.rotate = layer.rotate; + } else if (config.layer.type !== 'text') { + this.selection.x = -100000; + this.selection.y = -100000; + this.selection.width = 0; + this.selection.height = 0; + } + } + + get_editor(layer) { + let editor = layerEditors.get(layer); + if (!editor) { + editor = new Text_editor_class(); + + // Convert legacy to new format + if (layer.params && layer.params.text) { + const params = layer.params; + let lines = []; + const textLines = layer.params.text.split('\n'); + const family = params.family && params.family.value? params.family.value : params.family; + for (const textLine of textLines) { + lines.push([ + { + text: textLine, + meta: { + family, + size: params.size, + bold: params.bold, + italic: params.italic, + fill_color: params.stroke ? '#ffffff00' : layer.color, + stroke_color: params.stroke ? layer.color : '#ffffff00', + stroke_size: params.stroke ? params.stroke_size : 0, + leading: 0 + } + } + ]); + } + params.boundary = 'box'; + params.kerning = 'metrics'; + params.halign = params.align ? (params.align.value ? params.align.value : params.align).toLowerCase() : 'left'; + params.valign = 'top'; + params.text_direction = 'ltr'; + params.wrap_direction = 'ttb'; + params.wrap = 'word'; + delete params.text; + delete params.family; + delete params.size; + delete params.bold; + delete params.italic; + delete params.stroke; + delete params.stroke_size; + delete params.align; + layer.data = lines; + layer.x -= 1; + + // Change leading offset so line height matches legacy line height calculation... need to load the font first to do this. + // This is an approximate calculation, but seems to be pretty close. + load_font_family({ family }, () => { + const line = layer.data[0]; + if (!line) return; + const span = line[0]; + if (!span) return; + const fontMetrics = editor.get_span_font_metrics(span, !fontLoadMap.get(span.meta.family || metaDefaults.family)); + const topBounds = fontMetrics.calculate_letter_bounds('M', 'top'); + span.meta.leading = (span.meta.size || metaDefaults.size) - fontMetrics.height; + layer.y += Math.abs(span.meta.leading) - (fontMetrics.baseline - topBounds.bottom); + editor.hasValueChanged = true; + editor.Base_layers.render(); + }); + } + + // Create initial layer data if new layer + if (!layer.data) { + const params = this.getParams(); + layer.data = [[{ + text: '', + meta: { + family: params.font.value !== metaDefaults.family && params.font.value ? params.font.value : undefined, + size: params.size !== metaDefaults.size && !isNaN(params.size) ? params.size : undefined, + bold: params.bold.value !== metaDefaults.bold ? params.bold.value : undefined, + italic: params.italic.value !== metaDefaults.italic ? params.italic.value : undefined, + underline: params.underline.value !== metaDefaults.underline ? params.underline.value : undefined, + strikethrough: params.strikethrough.value !== metaDefaults.strikethrough ? params.strikethrough.value : undefined, + fill_color: params.fill !== metaDefaults.fill_color ? params.fill : undefined, + stroke_color: params.stroke !== metaDefaults.stroke_color ? params.stroke : undefined, + stroke_size: params.stroke_size !== metaDefaults.stroke_size && !isNaN(params.stroke_size) ? params.stroke_size : undefined, + kerning: params.kerning !== metaDefaults.kerning && !isNaN(params.kerning) ? params.kerning : undefined, + leading: params.leading !== metaDefaults.leading && !isNaN(params.leading) ? params.leading : undefined + } + }]]; + } + + editor.set_lines(layer.data); + editor.Base_layers = this.Base_layers; + editor.layer = layer; + layerEditors.set(layer, editor); + } + if (layer._needs_update_data) { + delete layer._needs_update_data; + editor.hasValueChanged = true; + editor.set_lines(JSON.parse(JSON.stringify(layer.data))); + } + return editor; + } + + get_text_layer_at_mouse(e) { + const layers_sorted = this.Base_layers.get_sorted_layers(); + if (config.layer.type === 'text') { + layers_sorted.unshift(config.layer); + } + const mouse = this.get_mouse_info(e); + const clickableMargin = 5; + for (let layer of layers_sorted) { + if (layer.type === 'text') { + // TODO - account for rotation + if (mouse.x >= layer.x - clickableMargin && mouse.x <= layer.x + layer.width + clickableMargin && mouse.y >= layer.y - clickableMargin && mouse.y <= layer.y + layer.height + clickableMargin) { + return layer; + } + } + } + return null; + } + +} + +export default Text_class; \ No newline at end of file diff --git a/paintplus/frontend/tools/translator/config.php b/paintplus/frontend/tools/translator/config.php new file mode 100644 index 0000000..5899050 --- /dev/null +++ b/paintplus/frontend/tools/translator/config.php @@ -0,0 +1,32 @@ + + + + + + + Translator + + +

    Translator

    +
    + Helpers: + + + + +

    + Actions: + + or + + '.strtoupper($lang).' '; + } + ?> +

    + 0) { + try { + if ($_POST['action'] == 'Import') { + $translator->scan(); + $translator->extract(); + echo "
    "; print_r($translator->strings); echo "
    \n"; + } + if ($_POST['action'] == 'Filter') { + $translator->scan(); + $translator->extract(); + $translator->filter(); + echo "
    "; print_r($translator->strings); echo "
    \n"; + } + if ($_POST['action'] == 'Translate manually') { + //show form + $translator->prepare(); + + //translate + if (isset($_POST['in'])) { + $translation = $_POST['in']; + + $translator->scan(); + $translator->extract(); + $translator->filter(); + $translator->add_translation($translation); + $translator->show_merged(); + } + } + if ($_POST['action'] == 'Merge') { + $translator->merge(); + } + if (stripos($_POST['action'], 'Auto Translate') !== false) { + //prepare + $translator->scan(); + $translator->extract(); + $translator->filter(); + + $translator->auto_translate($_POST['action']); + } + if ($_POST['action'] == 'Generate empty.json') { + //prepare + $translator->scan(); + $translator->extract(); + $translator->filter(); + + $translator->save_empty(); + } + + } + catch (Exception $exc) { + echo '
    ERROR: ' . $exc->getMessage() . '
    '; + } + } + ?> +
    + + diff --git a/paintplus/frontend/tools/translator/libs/GoogleTranslate.php b/paintplus/frontend/tools/translator/libs/GoogleTranslate.php new file mode 100644 index 0000000..87d9e13 --- /dev/null +++ b/paintplus/frontend/tools/translator/libs/GoogleTranslate.php @@ -0,0 +1,137 @@ + + * @copyright 2016 Adrián Barrio Andrés + * @license https://opensource.org/licenses/GPL-3.0 GNU General Public License 3.0 + * @version 2.0 + * @link https://statickidz.com/ + */ + +/** + * Main class GoogleTranslate + * + * @package GoogleTranslate + * + */ +class GoogleTranslate +{ + + /** + * Retrieves the translation of a text + * + * @param string $source + * Original language of the text on notation xx. For example: es, en, it, fr... + * @param string $target + * Language to which you want to translate the text in format xx. For example: es, en, it, fr... + * @param string $text + * Text that you want to translate + * + * @return string a simple string with the translation of the text in the target language + */ + public static function translate($source, $target, $text) + { + // Request translation + $response = self::requestTranslation($source, $target, $text); + + // Get translation text + // $response = self::getStringBetween("onmouseout=\"this.style.backgroundColor='#fff'\">", "", strval($response)); + + // Clean translation + $translation = self::getSentencesFromJSON($response); + + return $translation; + } + + /** + * Internal function to make the request to the translator service + * + * @internal + * + * @param string $source + * Original language taken from the 'translate' function + * @param string $target + * Target language taken from the ' translate' function + * @param string $text + * Text to translate taken from the 'translate' function + * + * @return object[] The response of the translation service in JSON format + */ + protected static function requestTranslation($source, $target, $text) + { + + // Google translate URL + $url = "https://translate.google.com/translate_a/single?client=at&dt=t&dt=ld&dt=qca&dt=rm&dt=bd&dj=1&hl=es-ES&ie=UTF-8&oe=UTF-8&inputm=2&otf=2&iid=1dd3b944-fa62-4b55-b330-74909a99969e"; + + $fields = array( + 'sl' => urlencode($source), + 'tl' => urlencode($target), + 'q' => urlencode($text) + ); + + $max = 9000; + if(strlen($fields['q']) >= $max) + throw new \Exception("Maximum number of characters exceeded: ".strlen($fields['q'])."/$max"); + + // URL-ify the data for the POST + $fields_string = ""; + foreach ($fields as $key => $value) { + $fields_string .= $key . '=' . $value . '&'; + } + + rtrim($fields_string, '&'); + + // Open connection + $ch = curl_init(); + + // Set the url, number of POST vars, POST data + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_POST, count($fields)); + curl_setopt($ch, CURLOPT_POSTFIELDS, $fields_string); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_ENCODING, 'UTF-8'); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); + curl_setopt($ch, CURLOPT_USERAGENT, 'AndroidTranslate/5.3.0.RC02.130475354-53000263 5.1 phone TRANSLATE_OPM5_TEST_1'); + + // Execute post + $result = curl_exec($ch); + + // Close connection + curl_close($ch); + + return $result; + } + + /** + * Dump of the JSON's response in an array + * + * @param string $json + * The JSON object returned by the request function + * + * @return string A single string with the translation + */ + protected static function getSentencesFromJSON($json) + { + $sentencesArray = json_decode($json, true); + $sentences = ""; + + foreach ($sentencesArray["sentences"] as $s) { + $sentences .= isset($s["trans"]) ? $s["trans"] : ''; + } + + return $sentences; + } +} \ No newline at end of file diff --git a/paintplus/frontend/tools/translator/libs/translator.php b/paintplus/frontend/tools/translator/libs/translator.php new file mode 100644 index 0000000..8e26704 --- /dev/null +++ b/paintplus/frontend/tools/translator/libs/translator.php @@ -0,0 +1,416 @@ +isDir()) { + continue; + } + $this->files[] = $file->getPathname(); + } + } + else if (is_file($dir)) { + $this->files[] = $dir; + } + else { + throw new Exception('can not import object: ' . $dir); + } + } + } + + /** + * extracts strings from files + * + * @throws Exception + */ + public function extract() { + $this->strings = array(); + foreach ($this->files as $file) { + $content = file_get_contents($file); + if ($content == '') + throw new Exception('can not get file content: ' . $content); + + $strings = array(); + + //drop svg + $content = preg_replace('||i', '', $content); + + //drop exceptions + $content = preg_replace('|\/\/no-translate BEGIN[\s\S]+?\/\/no-translate END|mi', '', $content); + + //drop + $content = stripslashes($content); + + if (stripos($file, '.js') !== false) { + //json + + $ignore_matches = [ + '\.addEventListener', + '\.style\.', + 'aria-label', + '\.font', + 'Path2D\(', + ]; + foreach ($ignore_matches as $ignore_match) { + $content = preg_replace('/' . $ignore_match . '.*/', '', $content); + } + + $content = preg_replace('|[\r\n][ \t]*//.*|', "\n", $content); + + //extract between ' ' + $out = array(); + preg_match_all("/[']([^']*)[']/", $content, $out); + $strings = array_merge($strings, $out[1]); + + //extract between " " + $out = array(); + preg_match_all('/["]([^"]*)["]/', $content, $out); + $strings = array_merge($strings, $out[1]); + } + if (stripos($file, '.htm') !== false || true) { + //html + $ignore_attributes = [ + 'dir', + 'lang', + 'http-equiv', + 'content', + 'name', + 'rel', + 'style', + 'onclick', + 'type', + 'class', + 'id', + 'href', + 'onchange', + 'onKeyUp', + 'oninput', + 'src', + 'aria-label', + ]; + foreach ($ignore_attributes as $ignore_attribute) { + $content = preg_replace('/' . $ignore_attribute . '="[^"]*"/', '', $content); + } + + //extract between " " + $out = array(); + preg_match_all('/["]([^"]*)["]/', $content, $out); + //$strings = array_merge($strings, $out[1]); + //extract between > < + $out = array(); + preg_match_all('|>([^<]{1,200})<[^ ]|', $content, $out); + $strings = array_merge($strings, $out[1]); + } + + foreach ($strings as $string) { + if (trim($string) == '' || substr($string, 0, 2) == './') + continue; + + //remove tags + $string = preg_replace('/<[^>]*>/', ' ', $string); + $string = trim($string); + + $this->strings[] = $string; + } + } + + $this->strings = array_unique($this->strings); + sort($this->strings); + } + + /** + * filters out some strings + */ + public function filter() { + $copy = $this->strings; + $this->strings = array(); + foreach ($copy as $string) { + $string = trim($string); + if (is_numeric($string)) { + //number + continue; + } + if (strlen($string) < 2) { + //too short + continue; + } + if (preg_replace("/[^A-Z0-9]+/", "", $string[0]) == '') { + //first letter must be common uppercase letter or number + continue; + } + if (is_numeric($string[0]) && strpos($string, ' ') === false) { + //if first letter is number - try to skip some + continue; + } + if (strpos($string, '(') !== false && strpos($string, ')') !== false && strpos($string, '.') !== false) { + //function, not string + continue; + } + if (strpos($string, "\n") !== false || strpos($string, "\r") !== false) { + //multi-line + continue; + } + if (preg_replace("/[^a-z]+/", "", $string) == '') { + //all caps - not translatable + continue; + } + if (strlen($string) > 30 && strpos($string, " ") === false) { + //long word without spaces + continue; + } + + $this->strings[] = $string; + } + $this->strings = array_unique($this->strings); + $this->strings = array_values($this->strings); + } + + /** + * prepare strings for translating for user + * + * @throws Exception + */ + public function prepare() { + $this->scan(); + $this->extract(); + $this->filter(); + + $data = $this->strings; + + $in_content = ''; + if (isset($_POST['in'])) + $in_content = $_POST['in']; + + echo '

    '; + echo 'Translate text above with translator and paste result below:

    '; + echo '
    '; + echo ''; + } + + /** + * combines source strings and manually translated strings to json format + * + * @param string $translation + * + * @throws Exception + */ + public function add_translation($translation) { + $translation = trim($translation); + if ($translation != '') + $translation = explode("\n", $translation); + else + $translation = array(); + + if (count($this->strings) == 0) + throw new Exception('0 translations found in files.'); + if (count($this->strings) != count($translation)) + throw new Exception(count($this->strings) . ' translations imported from file, but you provided ' . count($translation) . ', it must match'); + + $this->translations = new stdClass(); + foreach ($this->strings as $key => $value) { + $translated = trim($translation[$key]); + + $this->translations->$value = $translated; + } + } + + /** + * translates everything automatically + * + * @throws Exception + */ + public function auto_translate($action_string) { + global $LANGUAGES, $LANG_DIR; + + $action_string = str_replace('Auto Translate: ', '', $action_string); + if($action_string == 'all'){ + $action_string = ''; + } + + $service = new GoogleTranslate(); + $text = implode("\n", $this->strings); + + foreach ($LANGUAGES as $lang) { + if($action_string != '' && $action_string != $lang){ + continue; + } + + echo "
    $lang: "; + + $file_path = $LANG_DIR . strtolower($lang) . ".json"; + + //read old translations + $old = array(); + if (file_exists($file_path)) { + $old = file_get_contents($file_path); + if ($old === false) + throw new Exception('can not open file: ' . $file_path); + $old = json_decode($old); + if ($old === null) + throw new Exception($file_path . ' data is not json'); + } + + $translation = $service->translate('en', $lang, $text); + if ($translation == '') { + throw new Exception('empty response from translation service'); + } + $translation = str_replace("\r", '', $translation); + $translation = explode("\n", $translation); + if (count($this->strings) != count($translation)) { + throw new Exception(count($this->strings) . ' translations imported from file, but service gave: ' . count($translation) . ', it must match'); + } + + //generate array + $this->translations = new stdClass(); + foreach ($this->strings as $key => $value) { + $translated = trim($translation[$key]); + + $this->translations->$value = $translated; + } + + //merge + $merged = (object) array_merge((array) $this->translations, (array) $old); + + //remove not use elements + foreach ($merged as $k => $v) { + if (isset($this->translations->$k) == false) { + $v = null; + unset($merged->$k); + } + } + $this->translations = $merged; + + //generate JSON + $html = json_encode($this->translations, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + //save + $written = file_put_contents($file_path, $html); + if ($written == 0) { + throw new Exception('can not write to: ' . $file_path); + } + else { + echo 'OK'; + } + + //sleep 05-1s + usleep(rand(500, 1000) * 1000); + } + } + + /** + * saves current data as empty file + * + * @throws Exception + */ + public function save_empty() { + global $LANG_DIR_EMPTY; + + if ($LANG_DIR_EMPTY == '') + return; + + $data = new stdClass(); + foreach($this->strings as $value){ + $data->$value = ''; + } + + $data_encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + + $written = file_put_contents($LANG_DIR_EMPTY, $data_encoded); + if ($written == 0) { + throw new Exception('can not write to: ' . $LANG_DIR_EMPTY); + } + else { + echo '

    File updated: ' . $LANG_DIR_EMPTY . '

    '; + } + } + + /** + * show formatted translation, use json. parameters are only for testing mode + */ + public function show_merged() { + echo ''; + } + + /** + * merge two translations + * + * @throws Exception + */ + public function merge() { + echo 'Old translations: (priority on same keys)
    '; + $value = ''; + if (isset($_POST['merge_old'])) + $value = $_POST['merge_old']; + echo ''; + + echo '

    '; + + echo 'New translations:
    '; + $value = ''; + if (isset($_POST['merge_new'])) + $value = $_POST['merge_new']; + echo ''; + echo '

    '; + + if (isset($_POST['merge_old']) == false) + return; + + $old = json_decode($_POST['merge_old']); + $new = json_decode($_POST['merge_new']); + + if ($old === null) + throw new Exception('Old data is not json'); + if ($new === null) + throw new Exception('New data is not json'); + + //merge + $merged = (object) array_merge((array) $new, (array) $old); + + //remove not use elements + foreach ($merged as $k => $v) { + if (isset($new->$k) == false) { + $v = null; + unset($merged->$k); + } + } + + echo ''; + } + +} diff --git a/paintplus/frontend/vite.config.js b/paintplus/frontend/vite.config.js new file mode 100644 index 0000000..3535cb2 --- /dev/null +++ b/paintplus/frontend/vite.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/projects': { + target: 'http://backend:8000', + changeOrigin: true, + }, + '/edits': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/paintplus/frontend/webpack.config.js b/paintplus/frontend/webpack.config.js new file mode 100644 index 0000000..30ba843 --- /dev/null +++ b/paintplus/frontend/webpack.config.js @@ -0,0 +1,56 @@ +var webpack = require('webpack'); +var path = require('path'); + +module.exports = { + entry: [ + './src/js/main.js', + ], + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'bundle.js', + publicPath: '/dist/' + }, + resolve: { + extensions: ['.js', '.css'], + alias: { + Utilities: path.resolve(__dirname, './../node_modules/') + } + }, + module: { + rules: [ + { + test: /\.css$/, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: {url: false} + } + ] + }, + { + test: /\.js$/, + exclude: /(node_modules|bower_components)/, + use: ['babel-loader'] + }, + ] + }, + plugins: [ + new webpack.ProvidePlugin({ + $: "jquery", + jQuery: "jquery", + "window.jQuery": "jquery" + }), + new webpack.DefinePlugin({ + VERSION: JSON.stringify(require("./package.json").version) + }), + ], + devtool: "cheap-module-source-map", + devServer: { + // host: '0.0.0.0', + //contentBase: "./", + static: { + directory: path.resolve(__dirname, "./"), + }, + } +}; \ No newline at end of file diff --git a/paintplus/install-local-gpu.sh b/paintplus/install-local-gpu.sh new file mode 100755 index 0000000..0e5d7da --- /dev/null +++ b/paintplus/install-local-gpu.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# install-local-gpu.sh — one-time setup for local GPU inference. +# +# Run this once on a new machine. It: +# 1. Checks prerequisites (Docker, NVIDIA driver, curl) +# 2. Installs the NVIDIA container toolkit (so Docker can use the GPU) +# 3. Installs a systemd service that permanently fixes Docker container DNS +# (allows containers to resolve hostnames — does not touch ufw) +# 4. Restarts Docker so both changes take effect +# 5. Verifies the GPU is accessible inside Docker +# +# After this, use ./bring-up-local-gpu.sh each time to start the app. + +set -euo pipefail + +# ── Must run as root (or via sudo) ─────────────────────────────────────────── +if [ "$EUID" -ne 0 ]; then + exec sudo bash "$0" "$@" +fi + +echo "==================================================" +echo " EditmaskwithAI — Local GPU one-time setup" +echo "==================================================" +echo "" + +# ── 0. Prerequisite checks ──────────────────────────────────────────────────── +MISSING=0 + +# Docker +if ! command -v docker &>/dev/null; then + echo "✗ Docker is not installed." + echo " Install it from https://docs.docker.com/engine/install/" + echo " Quick install (Ubuntu/Debian):" + echo " curl -fsSL https://get.docker.com | sh" + echo " sudo usermod -aG docker \$USER # then log out and back in" + echo "" + MISSING=1 +else + echo "✓ Docker $(docker --version | awk '{print $3}' | tr -d ',')" +fi + +# Docker daemon running +if command -v docker &>/dev/null && ! docker info &>/dev/null 2>&1; then + echo "✗ Docker daemon is not running." + echo " sudo systemctl start docker" + echo "" + MISSING=1 +fi + +# NVIDIA driver +if ! command -v nvidia-smi &>/dev/null; then + echo "✗ NVIDIA driver not found (nvidia-smi missing)." + echo " Install the driver first (≥ 525 required for CUDA 12.x):" + echo " Ubuntu: sudo apt install nvidia-driver-525" + echo " Or download from https://www.nvidia.com/drivers" + echo " After installing, reboot before re-running this script." + echo "" + MISSING=1 +else + DRIVER_VER=$(nvidia-smi --query-gpu=driver_version --format=csv,noheader 2>/dev/null | head -1 || echo "unknown") + echo "✓ NVIDIA driver $DRIVER_VER" +fi + +# curl (used to fetch toolkit repo config) +if ! command -v curl &>/dev/null; then + echo "✗ curl is not installed." + echo " sudo apt install curl # or: sudo dnf install curl" + echo "" + MISSING=1 +else + echo "✓ curl" +fi + +if [ "$MISSING" -ne 0 ]; then + echo "──────────────────────────────────────────────────" + echo " Fix the above issues then re-run this script." + echo "==================================================" + exit 1 +fi + +echo "" + +# ── 0.5. Pre-create ./data with correct ownership ──────────────────────────── +# Docker's daemon (always root) auto-creates bind-mount source directories on +# the first 'compose up' if they don't exist yet — leaving ./data root-owned +# and blocking the invoking user from later running ./prefetch-models.sh +# without a manual 'sudo chown'. Create it now, owned by the real (non-root) +# user, so that problem has no chance to happen on a fresh checkout. +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +mkdir -p "$REPO_DIR"/data/{models,hf_cache,projects,patches} +chown -R "${SUDO_UID:-$(id -u)}:${SUDO_GID:-$(id -g)}" "$REPO_DIR/data" +echo "✓ ./data prepared (writable without sudo)" +echo "" + +# ── 1. NVIDIA container toolkit ────────────────────────────────────────────── +if command -v nvidia-ctk &>/dev/null; then + echo "✓ nvidia-container-toolkit already installed — skipping" +else + echo "Installing nvidia-container-toolkit..." + . /etc/os-release + case "$ID" in + ubuntu|debian) + curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ + | gpg --dearmor -o /usr/share/keyrings/nvidia-ctk.gpg + curl -fsSL "https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list" \ + | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-ctk.gpg] https://#g' \ + | tee /etc/apt/sources.list.d/nvidia-container-toolkit.list + apt-get update -qq + apt-get install -y nvidia-container-toolkit + ;; + rhel|fedora|rocky|centos|almalinux) + dnf install -y nvidia-container-toolkit + ;; + *) + echo "⚠ Unrecognised distro ($ID). Install nvidia-container-toolkit manually." + echo " See: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html" + ;; + esac +fi + +nvidia-ctk runtime configure --runtime=docker + +# ── 2. Permanent Docker DNS fix via systemd ─────────────────────────────────── +# Adds a rule to the DOCKER-USER iptables chain so containers can resolve +# hostnames. Runs after docker.service on every boot. Does NOT touch ufw. +echo "" +echo "Installing docker-dns-fix systemd service..." + +cat > /etc/systemd/system/docker-dns-fix.service << 'EOF' +[Unit] +Description=Allow Docker containers to resolve DNS (DOCKER-USER iptables rule) +After=docker.service +Requires=docker.service +BindsTo=docker.service + +[Service] +Type=oneshot +ExecStart=/bin/sh -c \ + 'iptables -C DOCKER-USER -p udp --dport 53 -j ACCEPT 2>/dev/null || \ + iptables -I DOCKER-USER -p udp --dport 53 -j ACCEPT' +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable docker-dns-fix.service +echo "✓ docker-dns-fix.service installed and enabled" + +# ── 3. Restart Docker ───────────────────────────────────────────────────────── +echo "" +echo "Restarting Docker..." +systemctl restart docker +sleep 2 +echo "✓ Docker restarted" + +# ── 4. Apply DNS rule now (don't wait for next boot) ───────────────────────── +systemctl start docker-dns-fix.service +echo "✓ DNS fix applied" + +# ── 5. Verify GPU access ───────────────────────────────────────────────────── +echo "" +echo "Verifying GPU access inside Docker..." +if docker run --rm --gpus all nvidia/cuda:12.1.0-base-ubuntu22.04 nvidia-smi &>/dev/null; then + echo "✓ GPU is accessible inside Docker" +else + echo "⚠ GPU check failed. Is the NVIDIA driver installed on the host?" + echo " Check: nvidia-smi" + echo " Minimum driver version: 525" +fi + +# ── 6. Prefetch all AI models (host-side, outside Docker) ─────────────────── +# In-container DNS/network is unreliable on some hosts, so downloading on the +# host up front (including SDXL/inpaint, ~13GB) is the default now rather +# than a manual troubleshooting step. Best-effort: this script must finish +# (and leave the GPU/Docker setup done) even if prefetch fails outright. +# Run as the real invoking user, not root, so downloaded files (and any +# `pip install --user` side effects) end up owned by that user — this script +# itself is already running as root via the sudo re-exec above. +echo "" +echo "Prefetching AI models (this can take a while for SDXL, ~13GB)..." +if [ -n "${SUDO_USER:-}" ]; then + sudo -u "$SUDO_USER" -H "$REPO_DIR/prefetch-models.sh" --sdxl \ + || echo "⚠ Model prefetch had failures (see above) — continuing anyway, the container will retry in-container." +else + "$REPO_DIR/prefetch-models.sh" --sdxl \ + || echo "⚠ Model prefetch had failures (see above) — continuing anyway, the container will retry in-container." +fi + +echo "" +echo "==================================================" +echo " Setup complete." +echo " Start the app with: ./bring-up-local-gpu.sh" +echo "" +echo " Models are prefetched automatically by this script and by" +echo " bring-up-local-gpu.sh on every start. If any failed above (no" +echo " network, etc.), re-run manually any time:" +echo " ./prefetch-models.sh --sdxl" +echo "==================================================" diff --git a/paintplus/prefetch-models.sh b/paintplus/prefetch-models.sh new file mode 100755 index 0000000..5aa7ccc --- /dev/null +++ b/paintplus/prefetch-models.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# prefetch-models.sh — download AI models on the host, outside Docker. +# +# Use this when the container's outbound DNS/network is blocked (see +# README troubleshooting) and models can't be downloaded at container +# startup. Downloads land under ./data/, which both compose files already +# bind-mount into the container — so the container picks them up on next +# start with no rebuild and no in-container network access required. +# +# Usage: +# ./prefetch-models.sh # SAM + U2Net + BEN2 + BiRefNet-HR (~1.5GB) +# ./prefetch-models.sh --sdxl # also prefetch SDXL base + inpaint (~13GB) +# +# Safe to re-run: every download here skips files that already exist +# (HuggingFace Hub) or are already present (SAM/U2Net). Each model is +# independent — one failing (e.g. no network reachable at all) doesn't +# block the others from being attempted. + +set -uo pipefail + +cd "$(dirname "$0")" + +if ! command -v python3 &>/dev/null; then + echo "✗ python3 is required on the host for this script (Docker is not used here)." >&2 + echo " Ubuntu/Debian: sudo apt install python3 python3-pip" >&2 + exit 1 +fi + +# mkdir -p succeeds silently on an already-existing directory even when we +# can't write into it, so actually test writability rather than trusting that. +check_writable() { + mkdir -p "$1" 2>/dev/null + touch "$1/.write_test" 2>/dev/null && rm -f "$1/.write_test" +} + +NEED_CHOWN=0 +for d in data/models data/hf_cache; do + check_writable "$d" || NEED_CHOWN=1 +done + +if [ "$NEED_CHOWN" -eq 1 ]; then + echo "⚠ ./data isn't writable by $(id -un) — this usually means Docker created it as root on a previous run." + echo " Fixing ownership (the container runs as root and will still work fine afterward):" + echo " sudo chown -R $(id -u):$(id -g) ./data" + if ! sudo chown -R "$(id -u):$(id -g)" ./data; then + echo "✗ Could not fix ownership automatically (sudo failed or unavailable)." >&2 + echo " Run this manually, then re-run this script:" >&2 + echo " sudo chown -R \$(id -u):\$(id -g) ./data" >&2 + exit 1 + fi + for d in data/models data/hf_cache; do + check_writable "$d" || { echo "✗ Still no write permission in ./$d after chown." >&2; exit 1; } + done + echo "✓ Fixed." +fi + +PREFETCH_SDXL=0 +if [ "${1:-}" = "--sdxl" ]; then + PREFETCH_SDXL=1 +fi + +FAILED=() + +echo "==================================================" +echo " Prefetching AI models (host-side, no Docker)" +echo "==================================================" + +echo "" +echo "── SAM (Smart Select) ───────────────────────────────" +python3 scripts/download_sam_model.py vit_b || FAILED+=("SAM") + +echo "" +echo "── U2Net (Remove Background fallback) ──────────────" +python3 scripts/download_u2net_model.py u2net || FAILED+=("U2Net") + +echo "" +echo "── HuggingFace Hub models (BEN2, BiRefNet-HR) ───────" + +if ! python3 -c "import huggingface_hub" &>/dev/null; then + echo "Installing huggingface_hub (lightweight — no torch/GPU needed for this step)..." + PIP_ERR=$(python3 -m pip install --quiet --user "huggingface_hub>=0.23.0" 2>&1) || { + if echo "$PIP_ERR" | grep -q "externally-managed-environment"; then + # PEP 668 (Debian/Ubuntu 12+): --user already keeps this out of + # apt-managed system site-packages, so overriding here is safe. + echo "System Python is externally managed — retrying with --break-system-packages" + python3 -m pip install --quiet --user --break-system-packages "huggingface_hub>=0.23.0" \ + || FAILED+=("huggingface_hub install") + else + echo "$PIP_ERR" >&2 + FAILED+=("huggingface_hub install") + fi + } +fi + +if python3 -c "import huggingface_hub" &>/dev/null; then + # HF_HOME must match what the container resolves by default: the bind mount + # maps ./data/hf_cache -> /root/.cache/huggingface, and the container never + # sets HF_HOME explicitly, so it defaults to ~/.cache/huggingface there. + # huggingface_hub itself appends "/hub" to HF_HOME to get the actual cache + # root (HF_HUB_CACHE) — setting HF_HOME here (instead of passing --cache-dir + # or cache_dir=... directly) lets both sides derive that "/hub" nesting the + # same way, rather than us hardcoding it and risking a mismatch. + export HF_HOME="$(pwd)/data/hf_cache" + + PREFETCH_SDXL="$PREFETCH_SDXL" python3 - << 'PYEOF' || FAILED+=("HuggingFace models") +import os +from huggingface_hub import snapshot_download + +repos = ["PramaLLC/BEN2", "zhengpeng7/BiRefNet_HR"] +if os.environ.get("PREFETCH_SDXL") == "1": + repos += [ + "stabilityai/stable-diffusion-xl-base-1.0", + "diffusers/stable-diffusion-xl-1.0-inpainting-0.1", + ] + +for repo_id in repos: + print(f"\nDownloading {repo_id} ...") + snapshot_download(repo_id=repo_id, ignore_patterns=["*.msgpack", "flax_*", "tf_*"]) + print(f" done: {repo_id}") +PYEOF +else + echo "⚠ Skipping BEN2/BiRefNet-HR — huggingface_hub unavailable (install failed above)" + FAILED+=("HuggingFace models") +fi + +echo "" +echo "==================================================" +if [ ${#FAILED[@]} -eq 0 ]; then + echo " Done. Models cached under ./data/models and ./data/hf_cache" + if [ "$PREFETCH_SDXL" != "1" ]; then + echo " (SDXL not included — re-run with --sdxl to also prefetch txt2img/inpaint, ~13GB)" + fi + echo " Start the app: ./bring-up-local-gpu.sh" +else + echo " Finished with failures: ${FAILED[*]}" + echo " If ALL of the above failed, this host can't reach the internet right now" + echo " (check: curl -v https://github.com) — that's a host/network issue, not Docker." + echo " If only some failed, re-run this script to retry just those." +fi +echo "==================================================" diff --git a/paintplus/scripts/README.md b/paintplus/scripts/README.md new file mode 100644 index 0000000..d0318d2 --- /dev/null +++ b/paintplus/scripts/README.md @@ -0,0 +1,108 @@ +# Classic Eyes Scripts + +This directory contains scripts for generating and importing classic eye images into the AI Photo Edit patch library. + +## Overview + +The scripts generate a variety of classic eye styles that can be used as reusable patches for photo editing: + +- **Realistic eyes** - Detailed eyes with gradient irises and realistic highlights +- **Anime eyes** - Large, expressive eyes in anime style with prominent highlights +- **Cartoon eyes** - Simple, bold cartoon-style eyes + +Each style is available in multiple iris colors: blue, green, brown, hazel, grey, and amber. + +## Scripts + +### 1. `generate_classic_eyes_ppm.py` + +Generates classic eye images using only the Python standard library (no dependencies required). + +```bash +python scripts/generate_classic_eyes_ppm.py +``` + +This creates PPM format images in `data/classic_eyes_ppm/`. PPM is a simple image format that can be converted to PNG later. + +### 2. `download_classic_eyes.py` + +Full-featured script that generates eyes and imports them directly into the patch library. Requires PIL/Pillow. + +```bash +# Run inside the backend container +docker exec -it ai-photo-edit-backend python /app/../scripts/download_classic_eyes.py + +# Or with the API running +python scripts/download_classic_eyes.py --api --base-url http://localhost:8101 + +# Or save to a directory for later import +python scripts/download_classic_eyes.py --output-dir ./my_eyes +``` + +### 3. `startup_import_eyes.py` + +Converts PPM files to PNG and imports them into the patch library. Run this inside the backend container. + +```bash +docker exec -it ai-photo-edit-backend python /app/../scripts/startup_import_eyes.py +``` + +### 4. `import_saved_eyes.py` + +Import previously saved eye images from a directory. + +```bash +python scripts/import_saved_eyes.py /path/to/saved/eyes +``` + +## Quick Start + +1. **Generate the eye images** (no dependencies needed): + ```bash + python scripts/generate_classic_eyes_ppm.py + ``` + +2. **Start the application**: + ```bash + docker compose up -d + ``` + +3. **Import the eyes**: + ```bash + docker exec -it ai-photo-edit-backend python /scripts/startup_import_eyes.py + ``` + +4. **Verify in the app**: Open the patch library in the UI to see the imported classic eyes. + +## Generated Eyes + +| Style | Colors Available | Size | +|-----------|-------------------------------------------|--------| +| Realistic | Blue, Green, Brown, Hazel, Grey, Amber | 200x200| +| Anime | Blue, Green, Brown, Hazel, Grey, Amber | 200x200| +| Cartoon | Blue, Green, Brown, Hazel, Grey, Amber | 200x200| + +Total: 18 unique eye variants + +## Extending + +To add more eye styles or colors, edit the `generate_classic_eyes_ppm.py` or `download_classic_eyes.py` scripts: + +```python +# Add new color +colors["purple"] = (128, 0, 128) + +# Add new style in create_classic_eye() function +elif style == "fantasy": + # Your custom eye drawing code + pass +``` + +## File Formats + +- **PPM**: Portable Pixmap format - simple, universal, generated without dependencies +- **PNG**: Preferred format for the patch library - converted from PPM using PIL + +## License + +The generated eye images are created programmatically and are free to use without restrictions. diff --git a/paintplus/scripts/download_classic_eyes.py b/paintplus/scripts/download_classic_eyes.py new file mode 100644 index 0000000..87219a6 --- /dev/null +++ b/paintplus/scripts/download_classic_eyes.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python3 +""" +Download and import classic eye images into the patch library. + +This script downloads open-source/public domain eye images and imports them +into the AI Photo Edit patch library for use as reusable eye patches. + +Sources: +- OpenGameArt.org (CC0/Public Domain game assets) +- Generated stylized eyes using PIL +- Public domain vintage illustrations + +Usage: + # Run inside the backend container or with backend dependencies: + python scripts/download_classic_eyes.py + + # Or run via API when app is running: + python scripts/download_classic_eyes.py --api --base-url http://localhost:8101 +""" + +import os +import sys +import io +import json +import argparse +from pathlib import Path +from datetime import datetime + +# Try to import dependencies +try: + from PIL import Image, ImageDraw, ImageFilter + HAS_PIL = True +except ImportError: + HAS_PIL = False + print("Warning: PIL not available. Install with: pip install Pillow") + +try: + import requests + HAS_REQUESTS = True +except ImportError: + HAS_REQUESTS = False + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +try: + from sqlalchemy.orm import Session + from app.database import SessionLocal, engine, Base + from app.models.patch import Patch + from app.services.patch_library import PatchLibraryService + HAS_BACKEND = True +except ImportError: + HAS_BACKEND = False + print("Warning: Backend modules not available. Use --api mode or run inside backend container.") + + +# Public domain eye image sources (CC0/Public Domain) +CLASSIC_EYE_URLS = [ + # OpenGameArt style eyes - these are placeholder URLs + # In production, you would use actual public domain image URLs +] + +# We'll generate classic stylized eyes instead since downloading from external +# sources can be unreliable. These are better quality and guaranteed available. + + +def create_classic_eye( + size: tuple = (200, 200), + iris_color: tuple = (70, 130, 180), # Steel blue + pupil_size_ratio: float = 0.3, + iris_size_ratio: float = 0.7, + style: str = "realistic" +) -> Image.Image: + """ + Generate a classic stylized eye image. + + Args: + size: Output image size (width, height) + iris_color: RGB color for the iris + pupil_size_ratio: Ratio of pupil to iris + iris_size_ratio: Ratio of iris to eye + style: "realistic", "anime", "cartoon", "vintage" + + Returns: + PIL Image with transparent background + """ + img = Image.new('RGBA', size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + center_x, center_y = size[0] // 2, size[1] // 2 + eye_radius = min(size) // 2 - 5 + iris_radius = int(eye_radius * iris_size_ratio) + pupil_radius = int(iris_radius * pupil_size_ratio) + + if style == "realistic": + # White of the eye (sclera) with slight pink tint + sclera_color = (250, 245, 240, 255) + draw.ellipse( + [center_x - eye_radius, center_y - eye_radius, + center_x + eye_radius, center_y + eye_radius], + fill=sclera_color, + outline=(180, 160, 150, 255), + width=2 + ) + + # Iris with gradient effect + for i in range(iris_radius, 0, -2): + ratio = i / iris_radius + color = ( + int(iris_color[0] * ratio + 40 * (1 - ratio)), + int(iris_color[1] * ratio + 40 * (1 - ratio)), + int(iris_color[2] * ratio + 40 * (1 - ratio)), + 255 + ) + draw.ellipse( + [center_x - i, center_y - i, center_x + i, center_y + i], + fill=color + ) + + # Pupil + draw.ellipse( + [center_x - pupil_radius, center_y - pupil_radius, + center_x + pupil_radius, center_y + pupil_radius], + fill=(10, 10, 10, 255) + ) + + # Highlight/reflection + highlight_x = center_x - pupil_radius // 2 + highlight_y = center_y - pupil_radius // 2 + highlight_radius = pupil_radius // 3 + draw.ellipse( + [highlight_x - highlight_radius, highlight_y - highlight_radius, + highlight_x + highlight_radius, highlight_y + highlight_radius], + fill=(255, 255, 255, 200) + ) + + elif style == "anime": + # Large iris, small pupil, big highlight - anime style + iris_radius = int(eye_radius * 0.85) + + # Sclera + draw.ellipse( + [center_x - eye_radius, center_y - eye_radius, + center_x + eye_radius, center_y + eye_radius], + fill=(255, 255, 255, 255), + outline=(0, 0, 0, 255), + width=3 + ) + + # Large iris + draw.ellipse( + [center_x - iris_radius, center_y - iris_radius, + center_x + iris_radius, center_y + iris_radius], + fill=iris_color + (255,) + ) + + # Pupil + pupil_radius = int(iris_radius * 0.25) + draw.ellipse( + [center_x - pupil_radius, center_y - pupil_radius, + center_x + pupil_radius, center_y + pupil_radius], + fill=(0, 0, 0, 255) + ) + + # Large anime-style highlight + hl_x, hl_y = center_x - iris_radius // 3, center_y - iris_radius // 3 + hl_r = iris_radius // 3 + draw.ellipse( + [hl_x - hl_r, hl_y - hl_r, hl_x + hl_r, hl_y + hl_r], + fill=(255, 255, 255, 255) + ) + + # Secondary smaller highlight + hl2_x, hl2_y = center_x + iris_radius // 4, center_y + iris_radius // 4 + hl2_r = iris_radius // 6 + draw.ellipse( + [hl2_x - hl2_r, hl2_y - hl2_r, hl2_x + hl2_r, hl2_y + hl2_r], + fill=(255, 255, 255, 200) + ) + + elif style == "cartoon": + # Simple cartoon eye + # Sclera + draw.ellipse( + [center_x - eye_radius, center_y - eye_radius, + center_x + eye_radius, center_y + eye_radius], + fill=(255, 255, 255, 255), + outline=(0, 0, 0, 255), + width=4 + ) + + # Simple colored iris + draw.ellipse( + [center_x - iris_radius, center_y - iris_radius, + center_x + iris_radius, center_y + iris_radius], + fill=iris_color + (255,), + outline=(0, 0, 0, 255), + width=2 + ) + + # Pupil + draw.ellipse( + [center_x - pupil_radius, center_y - pupil_radius, + center_x + pupil_radius, center_y + pupil_radius], + fill=(0, 0, 0, 255) + ) + + # Highlight + hl_r = pupil_radius // 2 + draw.ellipse( + [center_x - pupil_radius - hl_r, center_y - pupil_radius - hl_r, + center_x - pupil_radius + hl_r, center_y - pupil_radius + hl_r], + fill=(255, 255, 255, 255) + ) + + elif style == "vintage": + # Vintage engraving style eye + # Multiple concentric circles for hatching effect + draw.ellipse( + [center_x - eye_radius, center_y - eye_radius, + center_x + eye_radius, center_y + eye_radius], + fill=(245, 235, 220, 255), + outline=(80, 60, 40, 255), + width=2 + ) + + # Iris with hatching-like rings + for i in range(iris_radius, pupil_radius, -4): + ratio = (i - pupil_radius) / (iris_radius - pupil_radius) + alpha = int(150 + 100 * ratio) + draw.ellipse( + [center_x - i, center_y - i, center_x + i, center_y + i], + outline=(60 + int(iris_color[0] * 0.3), + 50 + int(iris_color[1] * 0.3), + 40 + int(iris_color[2] * 0.3), alpha), + width=1 + ) + + # Dark pupil + draw.ellipse( + [center_x - pupil_radius, center_y - pupil_radius, + center_x + pupil_radius, center_y + pupil_radius], + fill=(20, 15, 10, 255) + ) + + # Apply slight blur for more natural look + if style in ["realistic", "vintage"]: + img = img.filter(ImageFilter.GaussianBlur(radius=0.5)) + + return img + + +def generate_eye_variants() -> list: + """ + Generate a set of classic eye variants with different colors and styles. + + Returns: + List of (name, description, tags, image) tuples + """ + variants = [] + + # Eye colors + colors = { + "blue": (70, 130, 180), + "green": (60, 140, 90), + "brown": (139, 90, 43), + "hazel": (150, 120, 70), + "grey": (120, 130, 140), + "amber": (180, 130, 50), + "violet": (138, 43, 226), + "black": (30, 30, 35), + } + + # Styles + styles = ["realistic", "anime", "cartoon", "vintage"] + + # Sizes + sizes = { + "small": (100, 100), + "medium": (200, 200), + "large": (300, 300), + } + + # Generate all combinations for medium size, main styles + for style in styles: + for color_name, color_rgb in colors.items(): + size = sizes["medium"] + img = create_classic_eye( + size=size, + iris_color=color_rgb, + style=style + ) + + name = f"Classic {style.title()} Eye - {color_name.title()}" + description = f"A {style} style eye with {color_name} iris color" + tags = f"eye,classic,{style},{color_name},medium" + + variants.append((name, description, tags, img)) + + # Add some extra size variants for most popular combinations + popular = [ + ("blue", "realistic"), + ("brown", "realistic"), + ("green", "realistic"), + ("blue", "anime"), + ("green", "anime"), + ] + + for color_name, style in popular: + color_rgb = colors[color_name] + for size_name, size in sizes.items(): + if size_name == "medium": + continue # Already generated + + img = create_classic_eye( + size=size, + iris_color=color_rgb, + style=style + ) + + name = f"Classic {style.title()} Eye - {color_name.title()} ({size_name})" + description = f"A {size_name} {style} style eye with {color_name} iris" + tags = f"eye,classic,{style},{color_name},{size_name}" + + variants.append((name, description, tags, img)) + + return variants + + +def download_external_eyes() -> list: + """ + Download eye images from external public domain sources. + + Returns: + List of (name, description, tags, image) tuples + """ + if not HAS_REQUESTS or not HAS_PIL: + print(" Skipping external downloads (missing dependencies)") + return [] + + results = [] + + # OpenGameArt and other CC0 sources + # These are example URLs - in production, curate actual public domain images + external_sources = [ + { + "url": "https://opengameart.org/sites/default/files/eye_0.png", + "name": "OpenGameArt Eye Sprite", + "description": "Pixel art style eye from OpenGameArt (CC0)", + "tags": "eye,pixel,game,sprite,public_domain" + }, + ] + + for source in external_sources: + try: + response = requests.get(source["url"], timeout=10) + if response.status_code == 200: + img = Image.open(io.BytesIO(response.content)).convert('RGBA') + results.append(( + source["name"], + source["description"], + source["tags"], + img + )) + print(f" Downloaded: {source['name']}") + else: + print(f" Failed to download {source['name']}: HTTP {response.status_code}") + except Exception as e: + print(f" Error downloading {source['name']}: {e}") + + return results + + +def import_eyes_to_library(eyes: list, db: Session, patch_service: PatchLibraryService): + """ + Import eye images into the patch library database. + + Args: + eyes: List of (name, description, tags, image) tuples + db: Database session + patch_service: PatchLibraryService instance + """ + imported_count = 0 + + for name, description, tags, img in eyes: + try: + # Check if patch with same name already exists + existing = db.query(Patch).filter(Patch.name == name).first() + if existing: + print(f" Skipping (exists): {name}") + continue + + # Create database record first to get ID + patch = Patch( + name=name, + description=description, + source_type="imported", + width=img.width, + height=img.height, + tags=tags, + category="eye", + is_public=True, + file_path="", # Will update after saving + thumbnail_path="" + ) + db.add(patch) + db.flush() # Get the ID + + # Save image file + patch_path = patch_service.get_patch_path(patch.id) + img.save(patch_path, 'PNG') + + # Create thumbnail + thumb_path = patch_service.get_thumbnail_path(patch.id) + patch_service.create_thumbnail(patch_path, thumb_path) + + # Update paths in database + patch.file_path = f"patch_library/{patch.id}.png" + patch.thumbnail_path = f"patch_library/{patch.id}_thumb.png" + + db.commit() + imported_count += 1 + print(f" Imported: {name} (ID: {patch.id})") + + except Exception as e: + db.rollback() + print(f" Error importing {name}: {e}") + + return imported_count + + +def import_via_api(eyes: list, base_url: str) -> int: + """ + Import eyes via the REST API. + + Args: + eyes: List of (name, description, tags, image) tuples + base_url: Base URL of the API (e.g., http://localhost:8101) + + Returns: + Number of successfully imported eyes + """ + if not HAS_REQUESTS: + print("Error: requests library required for API mode") + return 0 + + imported = 0 + for name, description, tags, img in eyes: + try: + # Convert image to bytes + img_buffer = io.BytesIO() + img.save(img_buffer, format='PNG') + img_buffer.seek(0) + + # Upload via API + files = {'file': (f'{name}.png', img_buffer, 'image/png')} + data = { + 'name': name, + 'description': description, + 'tags': tags, + 'category': 'eye', + 'source_type': 'imported' + } + + response = requests.post( + f"{base_url}/patches/", + files=files, + data=data, + timeout=30 + ) + + if response.status_code in [200, 201]: + patch_id = response.json().get('id', 'unknown') + print(f" Imported: {name} (ID: {patch_id})") + imported += 1 + elif response.status_code == 409: + print(f" Skipping (exists): {name}") + else: + print(f" Failed to import {name}: HTTP {response.status_code}") + + except Exception as e: + print(f" Error importing {name}: {e}") + + return imported + + +def save_eyes_to_files(eyes: list, output_dir: Path) -> int: + """ + Save generated eyes as PNG files for manual import later. + + Args: + eyes: List of (name, description, tags, image) tuples + output_dir: Directory to save images + + Returns: + Number of saved files + """ + output_dir.mkdir(parents=True, exist_ok=True) + saved = 0 + + # Create a metadata file + metadata = [] + + for name, description, tags, img in eyes: + try: + # Create safe filename + safe_name = name.replace(' ', '_').replace('-', '_').lower() + safe_name = ''.join(c for c in safe_name if c.isalnum() or c == '_') + filename = f"{safe_name}.png" + + filepath = output_dir / filename + img.save(filepath, 'PNG') + + metadata.append({ + 'filename': filename, + 'name': name, + 'description': description, + 'tags': tags, + 'width': img.width, + 'height': img.height + }) + + saved += 1 + + except Exception as e: + print(f" Error saving {name}: {e}") + + # Write metadata JSON + metadata_path = output_dir / "metadata.json" + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + print(f" Saved metadata to: {metadata_path}") + + return saved + + +def main(): + """Main function to download and import classic eyes.""" + parser = argparse.ArgumentParser(description='Download and import classic eye images') + parser.add_argument('--api', action='store_true', help='Use API mode to import') + parser.add_argument('--base-url', default='http://localhost:8101', help='API base URL') + parser.add_argument('--output-dir', help='Save images to directory instead of importing') + parser.add_argument('--skip-external', action='store_true', help='Skip downloading external images') + args = parser.parse_args() + + print("=" * 60) + print("Classic Eye Importer for AI Photo Edit") + print("=" * 60) + + if not HAS_PIL: + print("\nError: PIL/Pillow is required. Install with: pip install Pillow") + print("Or run this script inside the backend container.") + sys.exit(1) + + all_eyes = [] + + # Generate stylized eyes + print("\n[1/3] Generating classic stylized eyes...") + generated_eyes = generate_eye_variants() + print(f" Generated {len(generated_eyes)} eye variants") + all_eyes.extend(generated_eyes) + + # Download external public domain eyes + if not args.skip_external: + print("\n[2/3] Downloading external public domain eyes...") + external_eyes = download_external_eyes() + print(f" Downloaded {len(external_eyes)} external eyes") + all_eyes.extend(external_eyes) + else: + print("\n[2/3] Skipping external downloads (--skip-external)") + + # Determine import method + print(f"\n[3/3] Processing {len(all_eyes)} eyes...") + + if args.output_dir: + # Save to files + output_dir = Path(args.output_dir) + print(f" Saving to directory: {output_dir}") + saved = save_eyes_to_files(all_eyes, output_dir) + print(f" Saved {saved} eye images to {output_dir}") + + elif args.api: + # Import via API + print(f" Using API mode: {args.base_url}") + imported = import_via_api(all_eyes, args.base_url) + print(f"\n Successfully imported: {imported}") + print(f" Skipped/Failed: {len(all_eyes) - imported}") + + elif HAS_BACKEND: + # Direct database import + print(" Using direct database import...") + Base.metadata.create_all(bind=engine) + db = SessionLocal() + + data_dir = os.environ.get("DATA_DIR", str(Path(__file__).parent.parent / "data")) + patch_service = PatchLibraryService(data_dir) + print(f" Patch library dir: {patch_service.patch_library_dir}") + + imported = import_eyes_to_library(all_eyes, db, patch_service) + + # Summary + print("\n" + "=" * 60) + print("Import Complete!") + print(f" Total eyes processed: {len(all_eyes)}") + print(f" Successfully imported: {imported}") + print(f" Skipped (duplicates): {len(all_eyes) - imported}") + print("=" * 60) + + # Show sample of imported eyes + print("\nSample of imported eyes:") + samples = db.query(Patch).filter(Patch.category == "eye").limit(5).all() + for p in samples: + print(f" - {p.name} ({p.width}x{p.height}) [ID: {p.id}]") + + db.close() + + else: + # Fallback: save to files + output_dir = Path(__file__).parent.parent / "data" / "classic_eyes_import" + print(f" Backend not available. Saving to: {output_dir}") + saved = save_eyes_to_files(all_eyes, output_dir) + print(f"\n Saved {saved} eye images") + print(f"\n To import later, run inside backend container:") + print(f" python scripts/import_saved_eyes.py {output_dir}") + + +if __name__ == "__main__": + main() diff --git a/paintplus/scripts/download_realesrgan.py b/paintplus/scripts/download_realesrgan.py new file mode 100644 index 0000000..4f35c8f --- /dev/null +++ b/paintplus/scripts/download_realesrgan.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Download Real-ESRGAN NCNN Vulkan binary. + +This gives you fast AI upscaling on ANY GPU (Intel/AMD/NVIDIA integrated or discrete, +Apple Metal) without needing CUDA or Python AI packages. + +Usage: + docker exec -it ai-photo-edit python /scripts/download_realesrgan.py + # or locally: + python scripts/download_realesrgan.py +""" + +import os +import sys +import platform +import zipfile +import urllib.request +import stat +from pathlib import Path + +DEST_DIR = Path("/app/data/models/realesrgan") +VERSION = "v0.2.5.0" + +PLATFORM_MAP = { + "linux": f"realesrgan-ncnn-vulkan-{VERSION}-ubuntu.zip", + "darwin": f"realesrgan-ncnn-vulkan-{VERSION}-macos.zip", + "win32": f"realesrgan-ncnn-vulkan-{VERSION}-windows.zip", + "windows": f"realesrgan-ncnn-vulkan-{VERSION}-windows.zip", +} + +BASE_URL = f"https://github.com/xinntao/Real-ESRGAN/releases/download/{VERSION}" + + +def main(): + plat = sys.platform.lower() + if plat not in PLATFORM_MAP: + print(f"Unknown platform: {plat}") + sys.exit(1) + + filename = PLATFORM_MAP[plat] + url = f"{BASE_URL}/{filename}" + zip_path = DEST_DIR / filename + + DEST_DIR.mkdir(parents=True, exist_ok=True) + + binary_name = "realesrgan-ncnn-vulkan.exe" if "win" in plat else "realesrgan-ncnn-vulkan" + binary_path = DEST_DIR / binary_name + + if binary_path.exists(): + print(f"Already installed: {binary_path}") + print("Delete it and re-run to reinstall.") + return + + print(f"Downloading Real-ESRGAN NCNN Vulkan {VERSION} for {plat}...") + print(f"URL: {url}") + + def progress(count, block_size, total_size): + if total_size > 0 and count % 100 == 0: + pct = min(100, count * block_size * 100 // total_size) + mb = count * block_size / 1024 / 1024 + total_mb = total_size / 1024 / 1024 + print(f" {pct}% ({mb:.1f}/{total_mb:.1f} MB)", end="\r") + + urllib.request.urlretrieve(url, zip_path, progress) + print(f"\nDownloaded to {zip_path}") + + print("Extracting...") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(DEST_DIR) + + # The zip extracts into a subdirectory — find the binary + found = list(DEST_DIR.rglob(binary_name)) + if not found: + print(f"ERROR: Could not find {binary_name} in extracted files.") + sys.exit(1) + + extracted = found[0] + if extracted != binary_path: + extracted.rename(binary_path) + + # Make executable on unix + if "win" not in plat: + binary_path.chmod(binary_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + # Clean up zip + zip_path.unlink(missing_ok=True) + + print(f"\nInstalled: {binary_path}") + print("\nTest it:") + print(f" {binary_path} --help") + print("\nThe upscaler will auto-detect this binary next time you use Upscale in PaintPlus.") + print("Restart the backend container to clear the capability cache:") + print(" docker-compose restart backend") + + +if __name__ == "__main__": + main() diff --git a/paintplus/scripts/download_sam_model.py b/paintplus/scripts/download_sam_model.py new file mode 100644 index 0000000..749d277 --- /dev/null +++ b/paintplus/scripts/download_sam_model.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Download SAM (Segment Anything Model) for local inference. + +This script downloads the SAM model checkpoint to a persistent directory +so it survives container rebuilds. + +Models available: +- sam_vit_b: ~375MB (default, good balance of speed/quality) +- sam_vit_l: ~1.2GB (better quality, slower) +- sam_vit_h: ~2.5GB (best quality, slowest) + +Usage: + python scripts/download_sam_model.py [model_type] + + model_type: vit_b (default), vit_l, or vit_h +""" + +import os +import sys +import urllib.request +from pathlib import Path + +# Model URLs from Meta's official releases +SAM_MODELS = { + 'vit_b': { + 'url': 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_b_01ec64.pth', + 'filename': 'sam_vit_b_01ec64.pth', + 'size': '375MB' + }, + 'vit_l': { + 'url': 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_l_0b3195.pth', + 'filename': 'sam_vit_l_0b3195.pth', + 'size': '1.2GB' + }, + 'vit_h': { + 'url': 'https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth', + 'filename': 'sam_vit_h_4b8939.pth', + 'size': '2.5GB' + } +} + +def create_symlink(symlink_path: Path, target_name: str): + """Best-effort convenience symlink. Never raises — a missing/stale + symlink is harmless (callers also check the real filename directly), + but data/models/ is often root-owned from a prior Docker run, which + makes unlink/symlink_to fail with PermissionError for other users.""" + try: + if symlink_path.exists() or symlink_path.is_symlink(): + symlink_path.unlink() + symlink_path.symlink_to(target_name) + print(f"Symlink created: {symlink_path} -> {target_name}") + except OSError as e: + print(f"(skipping symlink: {e})") + + +def download_with_progress(url: str, dest_path: Path): + """Download file with progress indicator""" + print(f"Downloading to: {dest_path}") + + def progress_hook(count, block_size, total_size): + percent = int(count * block_size * 100 / total_size) + mb_done = count * block_size / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + sys.stdout.write(f"\r Progress: {percent}% ({mb_done:.1f}/{mb_total:.1f} MB)") + sys.stdout.flush() + + urllib.request.urlretrieve(url, dest_path, progress_hook) + print("\n Download complete!") + +def main(): + # Determine model type + model_type = sys.argv[1] if len(sys.argv) > 1 else 'vit_b' + + if model_type not in SAM_MODELS: + print(f"Unknown model type: {model_type}") + print(f"Available: {', '.join(SAM_MODELS.keys())}") + sys.exit(1) + + model_info = SAM_MODELS[model_type] + + # Determine models directory + # Check if running in Docker (mounted volume) or locally + models_dir = Path('/app/data/models') + if not models_dir.exists(): + models_dir = Path(__file__).parent.parent / 'data' / 'models' + + models_dir.mkdir(parents=True, exist_ok=True) + + dest_path = models_dir / model_info['filename'] + + print("=" * 60) + print("SAM Model Downloader") + print("=" * 60) + print(f"Model: SAM {model_type.upper()}") + print(f"Size: {model_info['size']}") + print(f"License: Apache 2.0 (commercial use OK)") + print("=" * 60) + + # Check if already downloaded + if dest_path.exists(): + print(f"\nModel already exists at: {dest_path}") + print("To re-download, delete the file first.") + + create_symlink(models_dir / 'sam_model.pth', dest_path.name) + return + + print(f"\nDownloading SAM {model_type.upper()} ({model_info['size']})...") + print("This is a one-time download. The model will persist across rebuilds.") + print() + + try: + download_with_progress(model_info['url'], dest_path) + + symlink_path = models_dir / 'sam_model.pth' + create_symlink(symlink_path, dest_path.name) + + print() + print("=" * 60) + print("SUCCESS!") + print(f"Model saved to: {dest_path}") + print("=" * 60) + + except Exception as e: + print(f"\nError downloading model: {e}") + if dest_path.exists(): + dest_path.unlink() + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/paintplus/scripts/download_sample_eyes.py b/paintplus/scripts/download_sample_eyes.py new file mode 100755 index 0000000..953aa42 --- /dev/null +++ b/paintplus/scripts/download_sample_eyes.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +""" +Download sample classical eye images and import them into the patch catalog. + +These are public domain images from Wikimedia Commons of classical sculptures. +Run this script to populate the eye catalog with example eyes. + +Usage: + cd /home/user/EditmaskwithAI + python scripts/download_sample_eyes.py +""" + +import os +import sys +import requests +import sqlite3 +from pathlib import Path +from PIL import Image +from io import BytesIO + +# Add backend to path +sys.path.insert(0, str(Path(__file__).parent.parent / 'backend')) + +# Sample eye images - public domain classical sculpture references +# These URLs point to Wikimedia Commons images of ancient sculptures +SAMPLE_EYES = [ + { + 'name': 'Greek Serene - Left', + 'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Head_Hygieia_BM_550.jpg/220px-Head_Hygieia_BM_550.jpg', + 'tags': 'greek,serene,left,marble', + 'category': 'eyes', + 'description': 'Classical Greek style eye from Hygieia statue' + }, + { + 'name': 'Roman Portrait - Right', + 'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Bust_of_Emperor_Philip_the_Arab_-_Hermitage_Museum.jpg/220px-Bust_of_Emperor_Philip_the_Arab_-_Hermitage_Museum.jpg', + 'tags': 'roman,portrait,right,marble', + 'category': 'eyes', + 'description': 'Roman portrait style eye' + }, + { + 'name': 'Greek Classical - Pair', + 'url': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/35/Marble_head_of_a_veiled_woman_MET_DT229963.jpg/220px-Marble_head_of_a_veiled_woman_MET_DT229963.jpg', + 'tags': 'greek,classical,pair,marble,veiled', + 'category': 'eyes', + 'description': 'Greek classical style veiled woman' + }, +] + +def download_image(url: str) -> bytes: + """Download image from URL and return bytes""" + headers = { + 'User-Agent': 'Mozilla/5.0 (compatible; EyeCatalogDownloader/1.0)' + } + response = requests.get(url, headers=headers, timeout=30) + response.raise_for_status() + return response.content + +def create_patch_directory(patch_id: int, data_dir: Path) -> Path: + """Create directory for patch files""" + patch_dir = data_dir / 'patches' / str(patch_id) + patch_dir.mkdir(parents=True, exist_ok=True) + return patch_dir + +def save_patch_and_thumbnail(image_bytes: bytes, patch_dir: Path) -> tuple: + """Save patch image and create thumbnail""" + # Open image + img = Image.open(BytesIO(image_bytes)).convert('RGBA') + + # Save full size + patch_path = patch_dir / 'patch.png' + img.save(patch_path, 'PNG') + + # Create thumbnail (max 200x200) + thumb = img.copy() + thumb.thumbnail((200, 200), Image.Resampling.LANCZOS) + thumb_path = patch_dir / 'thumbnail.png' + thumb.save(thumb_path, 'PNG') + + return patch_path, thumb_path, img.size + +def import_eye_to_database(db_path: Path, eye_data: dict, patch_path: str, thumb_path: str, width: int, height: int) -> int: + """Insert patch record into database""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO patches (name, description, source_type, category, tags, file_path, thumbnail_path, width, height) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + eye_data['name'], + eye_data.get('description', ''), + 'imported', + eye_data['category'], + eye_data['tags'], + str(patch_path), + str(thumb_path), + width, + height + )) + + patch_id = cursor.lastrowid + conn.commit() + conn.close() + + return patch_id + +def main(): + # Determine paths - handle both Docker and local environments + # In Docker: script is at /scripts/, data is at /app/data/ + # Locally: script is at ./scripts/, data is at ./data/ + docker_data_dir = Path('/app/data') + local_data_dir = Path(__file__).parent.parent / 'data' + + if docker_data_dir.exists(): + data_dir = docker_data_dir + else: + data_dir = local_data_dir + + db_path = data_dir / 'ai_photo_edit.db' + + # Check if database exists + if not db_path.exists(): + print(f"Database not found at {db_path}") + print("Attempting to initialize database...") + # Try to import and initialize database + try: + sys.path.insert(0, str(Path('/app'))) + sys.path.insert(0, str(Path(__file__).parent.parent / 'backend')) + from app.database import init_db + init_db() + print("Database initialized successfully.") + except Exception as e: + print(f"Could not initialize database: {e}") + print("Please start the backend first to initialize the database.") + sys.exit(1) + + print(f"Using database: {db_path}") + print(f"Data directory: {data_dir}") + print() + + # Check if patches table exists + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='patches'") + if not cursor.fetchone(): + print("Patches table not found. Creating it...") + cursor.execute(''' + CREATE TABLE IF NOT EXISTS patches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR NOT NULL, + description TEXT, + source_type VARCHAR NOT NULL, + category VARCHAR, + tags TEXT, + file_path VARCHAR, + thumbnail_path VARCHAR, + width INTEGER, + height INTEGER, + source_project_id INTEGER, + source_edit_id INTEGER, + user_id INTEGER, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + conn.commit() + conn.close() + + successful = 0 + failed = 0 + + for eye_data in SAMPLE_EYES: + print(f"Downloading: {eye_data['name']}...") + + try: + # Download image + image_bytes = download_image(eye_data['url']) + + # Get next patch ID + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM patches") + next_id = cursor.fetchone()[0] + conn.close() + + # Create directory and save files + patch_dir = create_patch_directory(next_id, data_dir) + patch_path, thumb_path, (width, height) = save_patch_and_thumbnail(image_bytes, patch_dir) + + # Import to database + patch_id = import_eye_to_database(db_path, eye_data, patch_path, thumb_path, width, height) + + print(f" ✓ Imported as patch #{patch_id} ({width}x{height})") + successful += 1 + + except Exception as e: + print(f" ✗ Failed: {e}") + failed += 1 + + print() + print(f"Done! Imported {successful} eyes, {failed} failed.") + print() + print("You can now see the eyes in the Eye Catalog panel in the web UI.") + print("To add your own eyes:") + print(" 1. Click '+ Add Eye' in the Eye Catalog") + print(" 2. Upload a PNG image (transparency works best)") + print(" 3. Give it a name and tags") + +if __name__ == '__main__': + main() diff --git a/paintplus/scripts/download_u2net_model.py b/paintplus/scripts/download_u2net_model.py new file mode 100644 index 0000000..3f44b75 --- /dev/null +++ b/paintplus/scripts/download_u2net_model.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Download U2Net model for background removal. + +U2Net is a deep learning model for salient object detection, +commonly used for background removal tasks. + +Usage: + python download_u2net_model.py [model_type] + +Model types: + u2net - Full U2Net model (~176MB, best quality) + u2netp - Lightweight U2Net (~4MB, faster, good quality) + u2net_human_seg - Optimized for human segmentation (~176MB) + +Default: u2netp (good balance of quality and speed) +""" + +import os +import sys +import urllib.request +from pathlib import Path + +# Model URLs (from official U2Net repository releases) +MODEL_URLS = { + 'u2net': { + 'url': 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx', + 'filename': 'u2net.onnx', + 'size_mb': 176 + }, + 'u2netp': { + 'url': 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx', + 'filename': 'u2netp.onnx', + 'size_mb': 4 + }, + 'u2net_human_seg': { + 'url': 'https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net_human_seg.onnx', + 'filename': 'u2net_human_seg.onnx', + 'size_mb': 176 + } +} + + +def download_with_progress(url: str, dest_path: Path, expected_size_mb: int): + """Download file with progress indicator.""" + + print(f"Downloading from: {url}") + print(f"Expected size: ~{expected_size_mb}MB") + + def progress_hook(count, block_size, total_size): + if total_size > 0: + percent = min(100, count * block_size * 100 // total_size) + downloaded_mb = count * block_size / (1024 * 1024) + total_mb = total_size / (1024 * 1024) + sys.stdout.write(f"\rProgress: {percent}% ({downloaded_mb:.1f}/{total_mb:.1f} MB)") + sys.stdout.flush() + + try: + urllib.request.urlretrieve(url, str(dest_path), progress_hook) + print("\nDownload complete!") + return True + except Exception as e: + print(f"\nDownload failed: {e}") + return False + + +def main(): + # Determine model type + model_type = 'u2netp' # Default to lightweight model + if len(sys.argv) > 1: + model_type = sys.argv[1].lower() + + if model_type not in MODEL_URLS: + print(f"Unknown model type: {model_type}") + print(f"Available models: {', '.join(MODEL_URLS.keys())}") + sys.exit(1) + + model_info = MODEL_URLS[model_type] + + # Determine models directory + # Check if running in Docker container + if os.path.exists('/app/data/models'): + models_dir = Path('/app/data/models') + else: + # Local development + script_dir = Path(__file__).parent + models_dir = script_dir.parent / 'data' / 'models' + + models_dir.mkdir(parents=True, exist_ok=True) + + dest_path = models_dir / model_info['filename'] + + # Check if already downloaded + if dest_path.exists(): + print(f"Model already exists at: {dest_path}") + print("Delete the file to re-download.") + return + + print(f"Downloading U2Net model: {model_type}") + print(f"Destination: {dest_path}") + print("") + + success = download_with_progress( + model_info['url'], + dest_path, + model_info['size_mb'] + ) + + if success: + # Create symlink for easier access + symlink_path = models_dir / 'u2net.onnx' + if not symlink_path.exists() or symlink_path.is_symlink(): + if symlink_path.is_symlink(): + symlink_path.unlink() + try: + symlink_path.symlink_to(dest_path.name) + print(f"Created symlink: {symlink_path} -> {dest_path.name}") + except OSError: + # Symlinks may not work on all systems + pass + + print(f"\nU2Net model ({model_type}) downloaded successfully!") + print(f"Location: {dest_path}") + print("\nYou can now use background removal in the application.") + else: + print("\nFailed to download model. Please try again or download manually from:") + print(f" {model_info['url']}") + print(f" Save to: {dest_path}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/paintplus/scripts/generate_classic_eyes_ppm.py b/paintplus/scripts/generate_classic_eyes_ppm.py new file mode 100644 index 0000000..c76e010 --- /dev/null +++ b/paintplus/scripts/generate_classic_eyes_ppm.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Generate classic eye images using only the Python standard library. +Creates PPM format images that can be converted to PNG when PIL is available. + +This script requires NO external dependencies - it uses the PPM image format +which is a simple text/binary format readable by most image tools. + +Usage: + python scripts/generate_classic_eyes_ppm.py + +The generated PPM files can be converted to PNG using: + - PIL/Pillow: Image.open('eye.ppm').save('eye.png') + - ImageMagick: convert eye.ppm eye.png + - GIMP: Open and export as PNG +""" + +import os +import math +from pathlib import Path + + +def create_ppm(width: int, height: int) -> list: + """Create an empty PPM image as a 2D list of (R, G, B) tuples.""" + return [[(0, 0, 0) for _ in range(width)] for _ in range(height)] + + +def set_pixel(img: list, x: int, y: int, color: tuple): + """Set a pixel in the image, with bounds checking.""" + height = len(img) + width = len(img[0]) if height > 0 else 0 + if 0 <= x < width and 0 <= y < height: + img[y][x] = color + + +def draw_filled_circle(img: list, cx: int, cy: int, radius: int, color: tuple): + """Draw a filled circle.""" + for y in range(-radius, radius + 1): + for x in range(-radius, radius + 1): + if x*x + y*y <= radius*radius: + set_pixel(img, cx + x, cy + y, color) + + +def draw_circle_ring(img: list, cx: int, cy: int, radius: int, color: tuple, thickness: int = 1): + """Draw a circle outline.""" + for y in range(-radius - thickness, radius + thickness + 1): + for x in range(-radius - thickness, radius + thickness + 1): + dist_sq = x*x + y*y + if (radius - thickness)**2 <= dist_sq <= (radius + thickness)**2: + set_pixel(img, cx + x, cy + y, color) + + +def blend_color(c1: tuple, c2: tuple, ratio: float) -> tuple: + """Blend two colors. ratio=0 gives c1, ratio=1 gives c2.""" + return ( + int(c1[0] * (1 - ratio) + c2[0] * ratio), + int(c1[1] * (1 - ratio) + c2[1] * ratio), + int(c1[2] * (1 - ratio) + c2[2] * ratio) + ) + + +def save_ppm(img: list, filepath: str): + """Save image as PPM format (P6 binary).""" + height = len(img) + width = len(img[0]) if height > 0 else 0 + + with open(filepath, 'wb') as f: + # PPM header + f.write(f"P6\n{width} {height}\n255\n".encode()) + # Pixel data + for row in img: + for r, g, b in row: + f.write(bytes([r, g, b])) + + +def save_ppm_text(img: list, filepath: str): + """Save image as PPM format (P3 text - more portable).""" + height = len(img) + width = len(img[0]) if height > 0 else 0 + + with open(filepath, 'w') as f: + f.write(f"P3\n{width} {height}\n255\n") + for row in img: + line = ' '.join(f"{r} {g} {b}" for r, g, b in row) + f.write(line + '\n') + + +def create_classic_eye( + size: int = 200, + iris_color: tuple = (70, 130, 180), + style: str = "realistic" +) -> list: + """ + Generate a classic stylized eye image. + + Args: + size: Image size (square) + iris_color: RGB color for the iris + style: "realistic", "anime", "cartoon" + + Returns: + 2D list of (R, G, B) tuples + """ + img = create_ppm(size, size) + + cx, cy = size // 2, size // 2 + eye_radius = size // 2 - 5 + iris_radius = int(eye_radius * 0.7) + pupil_radius = int(iris_radius * 0.35) + + if style == "realistic": + # White of the eye (sclera) + sclera_color = (250, 245, 240) + draw_filled_circle(img, cx, cy, eye_radius, sclera_color) + + # Sclera outline + draw_circle_ring(img, cx, cy, eye_radius, (180, 160, 150), 2) + + # Iris with gradient effect (simplified) + for r in range(iris_radius, 0, -1): + ratio = r / iris_radius + color = blend_color((40, 40, 40), iris_color, ratio) + draw_circle_ring(img, cx, cy, r, color, 1) + + # Pupil + draw_filled_circle(img, cx, cy, pupil_radius, (10, 10, 10)) + + # Highlight + hl_x = cx - pupil_radius // 2 + hl_y = cy - pupil_radius // 2 + hl_r = pupil_radius // 3 + draw_filled_circle(img, hl_x, hl_y, hl_r, (255, 255, 255)) + + elif style == "anime": + # Larger iris for anime style + iris_radius = int(eye_radius * 0.85) + + # Sclera + draw_filled_circle(img, cx, cy, eye_radius, (255, 255, 255)) + draw_circle_ring(img, cx, cy, eye_radius, (0, 0, 0), 3) + + # Large iris + draw_filled_circle(img, cx, cy, iris_radius, iris_color) + + # Pupil + pupil_radius = int(iris_radius * 0.25) + draw_filled_circle(img, cx, cy, pupil_radius, (0, 0, 0)) + + # Large highlight + hl_x = cx - iris_radius // 3 + hl_y = cy - iris_radius // 3 + hl_r = iris_radius // 3 + draw_filled_circle(img, hl_x, hl_y, hl_r, (255, 255, 255)) + + # Secondary highlight + hl2_x = cx + iris_radius // 4 + hl2_y = cy + iris_radius // 4 + hl2_r = iris_radius // 6 + draw_filled_circle(img, hl2_x, hl2_y, hl2_r, (220, 220, 220)) + + elif style == "cartoon": + # Simple cartoon eye + draw_filled_circle(img, cx, cy, eye_radius, (255, 255, 255)) + draw_circle_ring(img, cx, cy, eye_radius, (0, 0, 0), 4) + + draw_filled_circle(img, cx, cy, iris_radius, iris_color) + draw_circle_ring(img, cx, cy, iris_radius, (0, 0, 0), 2) + + draw_filled_circle(img, cx, cy, pupil_radius, (0, 0, 0)) + + # Highlight + hl_x = cx - pupil_radius + hl_y = cy - pupil_radius + hl_r = pupil_radius // 2 + draw_filled_circle(img, hl_x, hl_y, hl_r, (255, 255, 255)) + + return img + + +def main(): + """Generate a set of classic eye images.""" + output_dir = Path(__file__).parent.parent / "data" / "classic_eyes_ppm" + output_dir.mkdir(parents=True, exist_ok=True) + + print("=" * 60) + print("Classic Eye Generator (PPM Format)") + print("=" * 60) + print(f"Output directory: {output_dir}") + + # Eye colors + colors = { + "blue": (70, 130, 180), + "green": (60, 140, 90), + "brown": (139, 90, 43), + "hazel": (150, 120, 70), + "grey": (120, 130, 140), + "amber": (180, 130, 50), + } + + styles = ["realistic", "anime", "cartoon"] + + metadata = [] + generated = 0 + + print("\nGenerating eyes...") + + for style in styles: + for color_name, color_rgb in colors.items(): + name = f"classic_{style}_{color_name}" + filename = f"{name}.ppm" + filepath = output_dir / filename + + img = create_classic_eye(size=200, iris_color=color_rgb, style=style) + save_ppm(img, str(filepath)) + + metadata.append({ + 'filename': filename.replace('.ppm', '.png'), # For after conversion + 'ppm_filename': filename, + 'name': f"Classic {style.title()} Eye - {color_name.title()}", + 'description': f"A {style} style eye with {color_name} iris color", + 'tags': f"eye,classic,{style},{color_name},medium", + 'width': 200, + 'height': 200 + }) + + generated += 1 + print(f" Generated: {name}.ppm") + + # Save metadata + import json + metadata_path = output_dir / "metadata.json" + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + print(f"\n Metadata saved to: {metadata_path}") + print(f"\nGenerated {generated} eye images in PPM format.") + + # Create conversion script + convert_script = output_dir / "convert_to_png.py" + with open(convert_script, 'w') as f: + f.write('''#!/usr/bin/env python3 +"""Convert PPM files to PNG using PIL.""" +from pathlib import Path +from PIL import Image + +ppm_dir = Path(__file__).parent +for ppm_file in ppm_dir.glob("*.ppm"): + png_file = ppm_file.with_suffix('.png') + img = Image.open(ppm_file) + img.save(png_file, 'PNG') + print(f"Converted: {ppm_file.name} -> {png_file.name}") +''') + + print(f"\n Conversion script: {convert_script}") + print("\nTo convert PPM to PNG, run inside the backend container:") + print(f" python {convert_script}") + print("\nOr use ImageMagick:") + print(f" cd {output_dir} && for f in *.ppm; do convert \"$f\" \"${{f%.ppm}}.png\"; done") + + +if __name__ == "__main__": + main() diff --git a/paintplus/scripts/gpu_setup.py b/paintplus/scripts/gpu_setup.py new file mode 100644 index 0000000..a090223 --- /dev/null +++ b/paintplus/scripts/gpu_setup.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +GPU setup script — runs at container startup. +Uses the same detection logic as the backend (gpu_detect.py) to show +exactly which models will be used before the server starts. +Non-fatal: any failure just prints a warning and startup continues. +""" +import os +import sys + + +def main(): + print("Detecting GPU capabilities…") + + try: + import torch + except ImportError: + print("⚠ PyTorch not installed — GPU detection skipped") + return + + if torch.cuda.is_available(): + props = torch.cuda.get_device_properties(0) + free_b, total_b = torch.cuda.mem_get_info(0) + vram_total = total_b / (1024 ** 3) + vram_free = free_b / (1024 ** 3) + major, minor = props.major, props.minor + cc = f"{major}.{minor}" + + fp16 = major >= 6 + bf16 = major >= 8 + fp8 = major > 8 or (major == 8 and minor >= 9) + int8 = major >= 7 + tc = major >= 7 + + flags = [] + if fp16: flags.append("fp16") + if bf16: flags.append("bf16") + if fp8: flags.append("fp8") + if int8: flags.append("int8") + if tc: flags.append("tensor-cores") + + print(f"✓ GPU : {props.name}") + print(f" VRAM : {vram_total:.1f} GB total | {vram_free:.1f} GB free") + print(f" Compute : CC {cc} ({', '.join(flags) or 'fp32 only'})") + + if major < 6: + print(f" ⚠ Pre-Pascal (CC {cc}): using fp32 — effective VRAM budget halved") + + elif hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + print("✓ Apple Silicon MPS GPU detected (fp32 mode)") + vram_total = vram_free = 0.0 + else: + print("⚠ No GPU detected — AI inference will use CPU (very slow)") + vram_total = vram_free = 0.0 + + provider = os.environ.get("AI_PROVIDER", "").lower() + if provider != "local_gpu": + print(f" AI_PROVIDER={provider!r} — local GPU not active, skipping model selection") + return + + # Import and run the full detection to show what was selected + try: + sys.path.insert(0, "/app") + from app.services.gpu_detect import detect_gpu + info = detect_gpu() + + print(f"\n Effective VRAM : {info.effective_vram_gb:.1f} GB (tier: {info.tier})") + print("\n Model selection:") + printed: set = set() + for op, spec in info.recommended.items(): + if spec is None: + print(f" {op:<12} → (none — will use existing upscaler)") + elif spec.model_id not in printed: + print(f" {op:<12} → [{spec.family}] {spec.model_id}") + print(f" mem_opt={spec.memory_opt} res={spec.native_res}px ~{spec.vram_fp16_gb}GB fp16") + printed.add(spec.model_id) + else: + print(f" {op:<12} → (same as above: {spec.model_id})") + + for w in info.warnings: + print(f"\n ⚠ {w}") + + auto_dl = os.environ.get("AUTO_DOWNLOAD_MODELS", "true").lower() + print() + if auto_dl == "true": + print(" AUTO_DOWNLOAD_MODELS=true") + print(" → Model files will download in background at startup.") + print(" → First request loads from local disk (20-60s, not internet).") + print(" → Track progress: GET /api/gpu/prefetch-status") + else: + print(" AUTO_DOWNLOAD_MODELS=false — models download on first request.") + + except Exception as exc: + print(f" (Could not run full detection: {exc})") + + print() + + +if __name__ == "__main__": + main() diff --git a/paintplus/scripts/import_saved_eyes.py b/paintplus/scripts/import_saved_eyes.py new file mode 100644 index 0000000..b25455c --- /dev/null +++ b/paintplus/scripts/import_saved_eyes.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Import previously saved eye images into the patch library. + +This script reads eye images from a directory (along with metadata.json) +and imports them into the patch library database. + +Usage: + python scripts/import_saved_eyes.py /path/to/saved/eyes + +This script must be run with backend dependencies available +(e.g., inside the backend container). +""" + +import os +import sys +import json +import argparse +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from PIL import Image +from sqlalchemy.orm import Session +from app.database import SessionLocal, engine, Base +from app.models.patch import Patch +from app.services.patch_library import PatchLibraryService + + +def import_from_directory(source_dir: Path, db: Session, patch_service: PatchLibraryService) -> int: + """ + Import eye images from a directory. + + Args: + source_dir: Directory containing eye images and metadata.json + db: Database session + patch_service: PatchLibraryService instance + + Returns: + Number of successfully imported images + """ + metadata_path = source_dir / "metadata.json" + + if not metadata_path.exists(): + print(f"Error: metadata.json not found in {source_dir}") + print("Scanning for PNG files instead...") + + # Fallback: import all PNG files with default metadata + png_files = list(source_dir.glob("*.png")) + metadata = [] + for png_file in png_files: + img = Image.open(png_file) + metadata.append({ + 'filename': png_file.name, + 'name': png_file.stem.replace('_', ' ').title(), + 'description': f'Imported eye image: {png_file.name}', + 'tags': 'eye,imported', + 'width': img.width, + 'height': img.height + }) + else: + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + imported = 0 + + for item in metadata: + try: + filename = item['filename'] + filepath = source_dir / filename + + if not filepath.exists(): + print(f" Skipping (file not found): {filename}") + continue + + name = item['name'] + description = item.get('description', '') + tags = item.get('tags', 'eye,imported') + + # Check if already exists + existing = db.query(Patch).filter(Patch.name == name).first() + if existing: + print(f" Skipping (exists): {name}") + continue + + # Load image + img = Image.open(filepath) + + # Create database record + patch = Patch( + name=name, + description=description, + source_type="imported", + width=img.width, + height=img.height, + tags=tags, + category="eye", + is_public=True, + file_path="", + thumbnail_path="" + ) + db.add(patch) + db.flush() + + # Save to patch library + patch_path = patch_service.get_patch_path(patch.id) + img.save(patch_path, 'PNG') + + # Create thumbnail + thumb_path = patch_service.get_thumbnail_path(patch.id) + patch_service.create_thumbnail(patch_path, thumb_path) + + # Update paths + patch.file_path = f"patch_library/{patch.id}.png" + patch.thumbnail_path = f"patch_library/{patch.id}_thumb.png" + + db.commit() + imported += 1 + print(f" Imported: {name} (ID: {patch.id})") + + except Exception as e: + db.rollback() + print(f" Error importing {item.get('filename', 'unknown')}: {e}") + + return imported + + +def main(): + parser = argparse.ArgumentParser(description='Import saved eye images to patch library') + parser.add_argument('source_dir', help='Directory containing eye images and metadata.json') + args = parser.parse_args() + + source_dir = Path(args.source_dir) + if not source_dir.exists(): + print(f"Error: Directory not found: {source_dir}") + sys.exit(1) + + print("=" * 60) + print("Eye Image Importer") + print("=" * 60) + + # Initialize database + print("\nInitializing database...") + Base.metadata.create_all(bind=engine) + db = SessionLocal() + + # Initialize patch library + data_dir = os.environ.get("DATA_DIR", str(Path(__file__).parent.parent / "data")) + patch_service = PatchLibraryService(data_dir) + + print(f"Source directory: {source_dir}") + print(f"Patch library: {patch_service.patch_library_dir}") + + # Import + print("\nImporting eyes...") + imported = import_from_directory(source_dir, db, patch_service) + + print("\n" + "=" * 60) + print(f"Import Complete! Imported {imported} eyes.") + print("=" * 60) + + db.close() + + +if __name__ == "__main__": + main() diff --git a/paintplus/scripts/init_database.py b/paintplus/scripts/init_database.py new file mode 100644 index 0000000..aa99aef --- /dev/null +++ b/paintplus/scripts/init_database.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" +Initialize the database before other startup scripts run. + +This ensures the database exists and has all required tables +before download_sample_eyes.py tries to use it. +""" + +import os +import sys +from pathlib import Path + +# Add backend to path - handle both Docker and local environments +# In Docker: backend is at /app/ +# Locally: backend is at ./backend/ +if Path('/app').exists(): + sys.path.insert(0, '/app') +else: + sys.path.insert(0, str(Path(__file__).parent.parent / 'backend')) + +def main(): + # Import after path setup + from app.database import engine, Base, init_db + from app.models import project, user, patch + + print("Initializing database...") + + # Create all tables + init_db() + + # Verify database was created - check both Docker and local paths + docker_db_path = Path('/app/data/ai_photo_edit.db') + local_db_path = Path('./data/ai_photo_edit.db') + + if docker_db_path.exists(): + print(f"✓ Database initialized at: {docker_db_path}") + elif local_db_path.exists(): + print(f"✓ Database initialized at: {local_db_path}") + else: + print("⚠ Database file not found at expected locations, but tables may still be created") + + print("Database initialization complete.") + +if __name__ == '__main__': + main() diff --git a/paintplus/scripts/startup_import_eyes.py b/paintplus/scripts/startup_import_eyes.py new file mode 100644 index 0000000..da149bc --- /dev/null +++ b/paintplus/scripts/startup_import_eyes.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Startup script to import classic eyes into the patch library. +This should be run once when the backend starts, or manually. + +Usage: + python scripts/startup_import_eyes.py + +This script: +1. Converts PPM files to PNG (if needed) +2. Imports all classic eyes into the patch library database +""" + +import os +import sys +import json +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +try: + from PIL import Image + from sqlalchemy.orm import Session + from app.database import SessionLocal, engine, Base + from app.models.patch import Patch + from app.services.patch_library import PatchLibraryService +except ImportError as e: + print(f"Error: Required modules not available: {e}") + print("Run this script inside the backend container.") + sys.exit(1) + + +def convert_ppm_to_png(ppm_dir: Path) -> int: + """Convert all PPM files to PNG in the given directory.""" + converted = 0 + for ppm_file in ppm_dir.glob("*.ppm"): + png_file = ppm_file.with_suffix('.png') + if not png_file.exists(): + try: + img = Image.open(ppm_file) + img.save(png_file, 'PNG') + converted += 1 + print(f" Converted: {ppm_file.name} -> {png_file.name}") + except Exception as e: + print(f" Error converting {ppm_file.name}: {e}") + return converted + + +def import_eyes(source_dir: Path, db: Session, patch_service: PatchLibraryService) -> int: + """Import eye images from the given directory.""" + metadata_path = source_dir / "metadata.json" + + if not metadata_path.exists(): + print(f" No metadata.json found in {source_dir}") + return 0 + + with open(metadata_path, 'r') as f: + metadata = json.load(f) + + imported = 0 + + for item in metadata: + try: + # Try PNG first, then PPM + filename = item.get('filename', item.get('ppm_filename', '')).replace('.ppm', '.png') + filepath = source_dir / filename + + if not filepath.exists(): + # Try PPM + filepath = source_dir / item.get('ppm_filename', filename.replace('.png', '.ppm')) + + if not filepath.exists(): + continue + + name = item['name'] + + # Check if already exists + existing = db.query(Patch).filter(Patch.name == name).first() + if existing: + continue # Silent skip for duplicates on startup + + # Load and convert if PPM + img = Image.open(filepath).convert('RGBA') + + # Create database record + patch = Patch( + name=name, + description=item.get('description', ''), + source_type="imported", + width=img.width, + height=img.height, + tags=item.get('tags', 'eye,classic'), + category="eye", + is_public=True, + file_path="", + thumbnail_path="" + ) + db.add(patch) + db.flush() + + # Save to patch library as PNG + patch_path = patch_service.get_patch_path(patch.id) + img.save(patch_path, 'PNG') + + # Create thumbnail + thumb_path = patch_service.get_thumbnail_path(patch.id) + patch_service.create_thumbnail(patch_path, thumb_path) + + # Update paths + patch.file_path = f"patch_library/{patch.id}.png" + patch.thumbnail_path = f"patch_library/{patch.id}_thumb.png" + + db.commit() + imported += 1 + print(f" Imported: {name} (ID: {patch.id})") + + except Exception as e: + db.rollback() + print(f" Error: {e}") + + return imported + + +def main(): + print("=" * 60) + print("Classic Eyes Startup Import") + print("=" * 60) + + # Find the classic eyes directory + data_dir = Path(os.environ.get("DATA_DIR", "/app/data")) + if not data_dir.exists(): + data_dir = Path(__file__).parent.parent / "data" + + ppm_dir = data_dir / "classic_eyes_ppm" + + if not ppm_dir.exists(): + print(f"\nNo classic eyes found at: {ppm_dir}") + print("Run generate_classic_eyes_ppm.py first.") + return + + # Initialize database + print("\nInitializing database...") + Base.metadata.create_all(bind=engine) + db = SessionLocal() + + # Initialize patch library + patch_service = PatchLibraryService(str(data_dir)) + + # Convert PPM to PNG + print("\nConverting PPM to PNG (if needed)...") + converted = convert_ppm_to_png(ppm_dir) + if converted > 0: + print(f" Converted {converted} files") + else: + print(" All files already converted") + + # Import eyes + print("\nImporting classic eyes...") + imported = import_eyes(ppm_dir, db, patch_service) + + # Count existing + total = db.query(Patch).filter(Patch.category == "eye").count() + + print("\n" + "=" * 60) + print(f"Import Complete!") + print(f" Newly imported: {imported}") + print(f" Total eye patches in library: {total}") + print("=" * 60) + + db.close() + + +if __name__ == "__main__": + main() diff --git a/services/paintplus.sh b/services/paintplus.sh new file mode 100644 index 0000000..f0db04b --- /dev/null +++ b/services/paintplus.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# services/paintplus.sh — PaintPlus: self-hosted AI photo editor. Paint a mask over +# an object, describe the change, and AI replaces only that region. +# +# The full application source is vendored in this repo under ./paintplus and is +# copied to ~/docker/paintplus/src at install time (no network clone). +# Based on EditmaskwithAI (github.com/outis1one/EditmaskwithAI). +# Part of the modular post-install system (sourced by setup.sh). +# +# Two deployment modes (chosen at install): +# Cloud — no GPU; uses a cloud provider (OpenAI gpt-image / Replicate). Lightweight. +# GPU — local inference via the app's own installer (NVIDIA, downloads ~13 GB). +# +# The app reads config from a .env the compose file interpolates +# (${AI_PROVIDER}, ${OPENAI_API_KEY}, ${SECRET_KEY}, ...). No env_file: directive. +# No built-in auth — protect with Authelia via Caddy. + +register_service paintplus utilities "AI photo editor — mask a region, AI replaces it (PaintPlus)" 3080 + +install_paintplus() { + require_docker || return 1 + log_info "Installing PaintPlus (mask-based AI photo editor)..." + + # Vendored application source lives in this repo at /paintplus + local SELF_DIR SRC_DIR + SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + SRC_DIR="$(cd "$SELF_DIR/.." && pwd)/paintplus" + + local PP_DIR="$DOCKER_DIR/paintplus" + local APP_DIR="$PP_DIR/src" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would copy vendored source $SRC_DIR -> $APP_DIR" + echo "[DRY-RUN] Would prompt for deployment mode (Cloud API / Local GPU)" + echo "[DRY-RUN] Would create .env (AI_PROVIDER, API key, SECRET_KEY)" + echo "[DRY-RUN] Cloud: docker compose up -d --build | GPU: install-local-gpu.sh + bring-up-local-gpu.sh" + echo "[DRY-RUN] Would offer Authelia SSO and configure Caddy (paintplus:8000, host port 3080)" + return 0 + fi + + if [ ! -d "$SRC_DIR" ]; then + log_error "Vendored PaintPlus source not found at $SRC_DIR" + return 1 + fi + + # ── Copy vendored source into the docker dir ────────────────────────────── + # Refreshes app files; leaves a user-edited .env and the override untouched + # (neither is part of the vendored source). + mkdir -p "$APP_DIR" + cp -a "$SRC_DIR/." "$APP_DIR/" + ensure_docker_dir_ownership "$PP_DIR" + cd "$APP_DIR" || return 1 + + # ── Deployment mode ─────────────────────────────────────────────────────── + echo "" + log_info "Deployment mode:" + log_info " 1) Cloud API No GPU needed. Uses OpenAI (gpt-image) or Replicate. Lightweight." + log_info " 2) Local GPU NVIDIA GPU; runs the app's installer and downloads ~13 GB of models." + echo "" + local PP_MODE="" + prompt_text "Mode [1=Cloud]:" "1" PP_MODE + + # ── .env from the app's template ────────────────────────────────────────── + if [ -f .env.example ]; then + cp .env.example .env + else + log_warning ".env.example missing — writing a fresh .env" + : > .env + fi + + # Upsert KEY=VALUE into ./.env (drop any existing/commented line, then append) + _pp_set_env() { + sed -i -E "/^#?[[:space:]]*$1=/d" .env + printf '%s=%s\n' "$1" "$2" >> .env + } + + local PP_PROVIDER="local_gpu" + if [[ "$PP_MODE" != "2" ]]; then + echo "" + log_info "Cloud provider:" + log_info " 1) OpenAI gpt-image editing. Key: https://platform.openai.com/api-keys" + log_info " 2) Replicate SDXL-inpaint / LaMa etc. Key: https://replicate.com/account/api-tokens" + echo "" + local _prov="" + prompt_text "Provider [1=OpenAI]:" "1" _prov + local _key="" + if [[ "$_prov" == "2" ]]; then + PP_PROVIDER="replicate" + prompt_text "Replicate API key (enter to set later):" "" _key + if [ -n "$_key" ]; then _pp_set_env REPLICATE_API_KEY "$_key" + else log_warning "No key entered — set REPLICATE_API_KEY in .env later"; fi + else + PP_PROVIDER="openai" + prompt_text "OpenAI API key (enter to set later):" "" _key + if [ -n "$_key" ]; then _pp_set_env OPENAI_API_KEY "$_key" + else log_warning "No key entered — set OPENAI_API_KEY in .env later"; fi + fi + else + local _hf="" + prompt_text "HuggingFace token (optional — for gated models, enter to skip):" "" _hf + [ -n "$_hf" ] && _pp_set_env HF_TOKEN "$_hf" + fi + + _pp_set_env AI_PROVIDER "$PP_PROVIDER" + _pp_set_env SECRET_KEY "$(generate_password 48)" + chmod 600 .env + mkdir -p data + ensure_docker_dir_ownership "$PP_DIR" + + # ── Caddy network override (cloud mode) ─────────────────────────────────── + # The base compose has no networks, so Caddy (on caddy_net) can't reach the + # container by name. This override — auto-merged with the default compose — + # attaches the app to caddy_net. The GPU compose runs with an explicit -f and + # does NOT merge overrides, so GPU mode is wired with `docker network connect`. + if [[ "$PP_MODE" != "2" ]] && [ -d "$DOCKER_DIR/caddy" ]; then + cat > docker-compose.override.yml << OVR +# Added by ubuntu-post-install so Caddy (on caddy_net) can reach this app by name. +services: + app: + networks: + - caddy_net +networks: + caddy_net: + external: true + name: ${SITE_CADDY_NET:-caddy_net} +OVR + fi + + # ── Bring the stack up ──────────────────────────────────────────────────── + local START_PP="" + if [[ "$PP_MODE" == "2" ]]; then + echo "" + log_warning "GPU mode runs the app's installer (NVIDIA toolkit, DNS fix) and downloads ~13 GB." + prompt_yn "Run the local-GPU installer now? (y/n):" "y" START_PP + if [[ "$START_PP" =~ ^[Yy]$ ]]; then + if [ -f install-local-gpu.sh ] && [ -f bring-up-local-gpu.sh ]; then + chmod +x install-local-gpu.sh bring-up-local-gpu.sh 2>/dev/null || true + bash install-local-gpu.sh || log_warning "install-local-gpu.sh reported an error — see output above" + bash bring-up-local-gpu.sh || log_warning "bring-up-local-gpu.sh failed — check: docker compose -f docker-compose.gpu.yml logs" + else + log_warning "GPU scripts not found — start manually per src/README.md" + fi + fi + else + prompt_yn "Build and start PaintPlus now? (y/n):" "y" START_PP + if [[ "$START_PP" =~ ^[Yy]$ ]]; then + docker compose up -d --build \ + && log_success "PaintPlus started" \ + || log_warning "Start failed — check: docker compose logs" + fi + fi + + # Ensure the running container is on caddy_net (covers GPU mode, where the + # override file above is not merged by the app's bring-up script). + if [ -d "$DOCKER_DIR/caddy" ] && [[ "$START_PP" =~ ^[Yy]$ ]]; then + docker network connect "$SITE_CADDY_NET" paintplus 2>/dev/null || true + fi + + # ── Auth + Caddy ────────────────────────────────────────────────────────── + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect PaintPlus with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "PaintPlus" "paintplus:8000" "paintplus" "$EXTRA_BLOCK" + + # ── README (deploy notes; the app's own docs are at src/README.md) ──────── + write_readme "$PP_DIR" << MD +# PaintPlus (deployment) + +Self-hosted AI photo editor: paint a mask over any object, describe what you +want, and the AI replaces just that region. The application source is vendored +in ubuntu-post-install and copied here to \`src/\` (no network clone). +App docs: \`src/README.md\`. Based on EditmaskwithAI. + +- URL: http://localhost:3080 +- No built-in login — protect via Authelia SSO if exposed. +- Provider: set \`AI_PROVIDER\` in \`src/.env\` (openai, replicate, local_gpu, stability, comfyui, invokeai). + +## Configure providers / keys +Edit \`src/.env\` then restart (the compose file interpolates these — no env_file): +\`\`\`bash +cd $APP_DIR +nano .env # AI_PROVIDER, OPENAI_API_KEY / REPLICATE_API_KEY, HF_TOKEN +docker compose up -d --build +\`\`\` +Keys: OpenAI https://platform.openai.com/api-keys · Replicate https://replicate.com/account/api-tokens + +## Cloud mode (no GPU) +\`\`\`bash +cd $APP_DIR +docker compose up -d --build # starts on http://localhost:3080 +docker compose logs -f +docker compose down +\`\`\` + +## Local GPU mode (NVIDIA, ~13 GB of models) +\`\`\`bash +cd $APP_DIR +./install-local-gpu.sh # toolkit + DNS fix + model prefetch +./bring-up-local-gpu.sh # docker compose -f docker-compose.gpu.yml up -d --build +\`\`\` +GPU auto-selects models by VRAM (FLUX >=24 GB, SDXL 12-24 GB, SD 1.5 <2 GB). + +## Update the app +Re-run the PaintPlus installer (copies the latest vendored source over \`src/\`, +keeping your \`src/.env\`), then rebuild: +\`\`\`bash +cd $APP_DIR && docker compose up -d --build +\`\`\` + +## Caddy +Reverse-proxied as \`paintplus:8000\` on \`${SITE_CADDY_NET:-caddy_net}\`. Cloud mode +joins that network via \`src/docker-compose.override.yml\`; GPU mode is attached +with \`docker network connect\` after start. +MD + + echo "" + echo " URL: http://localhost:3080" + echo " App dir: $APP_DIR" + echo " Provider: $PP_PROVIDER (change AI_PROVIDER in $APP_DIR/.env)" + echo "" +}