From 71c09b334224a2fdde876dd90452a5102490a716 Mon Sep 17 00:00:00 2001 From: Kayshen Xu Date: Tue, 17 Mar 2026 21:07:50 +0800 Subject: [PATCH] V0.4.2 (#46) * feat(docker): add Docker support with multi-stage build and CI workflow - Introduced a `.dockerignore` file to exclude unnecessary files from the Docker context. - Added a `Dockerfile` for multi-stage builds, optimizing the application for production with a slim runtime. - Created a GitHub Actions workflow (`docker.yml`) to automate the building and pushing of Docker images on version tag pushes. - Enhanced the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands. - Updated the canvas components to support new polygon shape and related functionalities, including UI adjustments for shape tools and appearance settings. This update significantly enhances the deployment process and expands the application's capabilities with Docker integration. * feat(docker): enhance Dockerfile with multi-stage builds and CLI variants - Updated the Dockerfile to include multiple image variants for different CLI tools, including Claude, Codex, OpenCode, and Copilot, alongside the base web app. - Improved the build process by separating the build stage and production stage, optimizing the final image size. - Added environment variables and commands for each variant to ensure proper execution in production. - Enhanced the README files in multiple languages to document the new Docker deployment options and usage instructions. This update significantly expands the Docker deployment capabilities, allowing users to choose the appropriate image variant based on their needs. --- .dockerignore | 13 +++ .github/workflows/docker.yml | 77 +++++++++++++ Dockerfile | 69 ++++++++++++ README.de.md | 49 +++++++++ README.es.md | 49 +++++++++ README.fr.md | 49 +++++++++ README.hi.md | 49 +++++++++ README.id.md | 49 +++++++++ README.ja.md | 49 +++++++++ README.ko.md | 49 +++++++++ README.md | 49 +++++++++ README.pt.md | 49 +++++++++ README.ru.md | 49 +++++++++ README.th.md | 49 +++++++++ README.tr.md | 49 +++++++++ README.vi.md | 49 +++++++++ README.zh-TW.md | 49 +++++++++ README.zh.md | 49 +++++++++ server/api/ai/connect-agent.ts | 66 +++++++++++- server/api/ai/install-agent.ts | 11 +- src/canvas/canvas-node-creator.ts | 16 +++ src/canvas/skia/skia-canvas.tsx | 102 +++++++++++++++++- src/canvas/skia/skia-engine.ts | 19 +++- src/canvas/skia/skia-overlays.ts | 84 +++++++++++++++ src/canvas/skia/skia-renderer.ts | 82 +++++++++++++- src/components/editor/shape-tool-dropdown.tsx | 5 +- src/components/panels/appearance-section.tsx | 85 +++++++++++---- src/components/panels/layer-item.tsx | 4 +- src/components/panels/property-panel.tsx | 1 + src/hooks/use-keyboard-shortcuts.ts | 1 + src/i18n/locales/de.ts | 9 ++ src/i18n/locales/en.ts | 9 ++ src/i18n/locales/es.ts | 9 ++ src/i18n/locales/fr.ts | 9 ++ src/i18n/locales/hi.ts | 9 ++ src/i18n/locales/id.ts | 9 ++ src/i18n/locales/ja.ts | 9 ++ src/i18n/locales/ko.ts | 9 ++ src/i18n/locales/pt.ts | 9 ++ src/i18n/locales/ru.ts | 9 ++ src/i18n/locales/th.ts | 9 ++ src/i18n/locales/tr.ts | 9 ++ src/i18n/locales/vi.ts | 9 ++ src/i18n/locales/zh-tw.ts | 9 ++ src/i18n/locales/zh.ts | 9 ++ src/types/pen.ts | 2 + src/utils/boolean-ops.ts | 19 +++- 47 files changed, 1482 insertions(+), 44 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..43c4218a9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.github +.vscode +.output +node_modules +dist +dist-electron +electron-dist +build +screenshot +*.md +!package.json +.openpencil-tmp diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 000000000..d98ec8aa3 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,77 @@ +name: Docker + +on: + push: + tags: ['v*'] + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build & Push (${{ matrix.variant }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + + strategy: + matrix: + include: + - variant: base + target: base + suffix: '' + - variant: claude + target: with-claude + suffix: '-claude' + - variant: codex + target: with-codex + suffix: '-codex' + - variant: opencode + target: with-opencode + suffix: '-opencode' + - variant: copilot + target: with-copilot + suffix: '-copilot' + - variant: full + target: full + suffix: '-full' + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + suffix=${{ matrix.suffix }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + target: ${{ matrix.target }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.variant }} + cache-to: type=gha,mode=max,scope=${{ matrix.variant }} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..e699965ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +# ── Stage 1: Build web app ── +FROM oven/bun:1 AS builder + +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile +COPY . . +RUN bun --bun run build + +# ── Stage 2: Base (web only, no CLI) ── +FROM oven/bun:1-slim AS base + +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ + +ENV NODE_ENV=production +ENV NITRO_HOST=0.0.0.0 +ENV NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + +# ── CLI variants ── + +FROM oven/bun:1 AS with-claude +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g @anthropic-ai/claude-code +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + +FROM oven/bun:1 AS with-codex +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g @openai/codex +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + +FROM oven/bun:1 AS with-opencode +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g opencode-ai +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + +FROM oven/bun:1 AS with-copilot +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g @github/copilot +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + +# ── Full: all CLI tools ── +FROM oven/bun:1 AS full +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g @anthropic-ai/claude-code @openai/codex opencode-ai @github/copilot +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] diff --git a/README.de.md b/README.de.md index 249e5b398..33c2ef28b 100644 --- a/README.de.md +++ b/README.de.md @@ -102,6 +102,55 @@ bun run electron:dev > **Voraussetzungen:** [Bun](https://bun.sh/) >= 1.0 und [Node.js](https://nodejs.org/) >= 18 +### Docker-Bereitstellung + +Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre Anforderungen: + +| Image | Größe | Enthält | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Nur Web-App | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Alle CLI-Tools | + +**Ausführen (nur Web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Mit AI CLI ausführen (z.B. Claude Code):** + +Der AI-Chat basiert auf Claude CLI OAuth-Login. Verwenden Sie ein Docker-Volume, um die Login-Sitzung beizubehalten: + +```bash +# Schritt 1 — Login (einmalig) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Schritt 2 — Starten +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Lokal bauen:** + +```bash +# Basis (nur Web) +docker build --target base -t openpencil . + +# Mit einem bestimmten CLI +docker build --target with-claude -t openpencil-claude . + +# Vollständig (alle CLIs) +docker build --target full -t openpencil-full . +``` + ## KI-natives Design **Vom Prompt zur UI** diff --git a/README.es.md b/README.es.md index 19230b251..ba1a87da7 100644 --- a/README.es.md +++ b/README.es.md @@ -102,6 +102,55 @@ bun run electron:dev > **Requisitos previos:** [Bun](https://bun.sh/) >= 1.0 y [Node.js](https://nodejs.org/) >= 18 +### Despliegue con Docker + +Hay varias variantes de imagen disponibles — elige la que se ajuste a tus necesidades: + +| Imagen | Tamaño | Incluye | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Solo aplicación web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI | + +**Ejecutar (solo web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Ejecutar con AI CLI (ej. Claude Code):** + +El chat de IA depende del inicio de sesión OAuth de Claude CLI. Usa un volumen Docker para persistir la sesión de inicio de sesión: + +```bash +# Paso 1 — Iniciar sesión (una sola vez) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Paso 2 — Iniciar +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Compilar localmente:** + +```bash +# Base (solo web) +docker build --target base -t openpencil . + +# Con un CLI específico +docker build --target with-claude -t openpencil-claude . + +# Completa (todos los CLIs) +docker build --target full -t openpencil-full . +``` + ## Diseño Nativo de IA **De Prompt a Interfaz** diff --git a/README.fr.md b/README.fr.md index 571e8dfaa..cd34e17a4 100644 --- a/README.fr.md +++ b/README.fr.md @@ -102,6 +102,55 @@ bun run electron:dev > **Prérequis :** [Bun](https://bun.sh/) >= 1.0 et [Node.js](https://nodejs.org/) >= 18 +### Déploiement Docker + +Plusieurs variantes d'images sont disponibles — choisissez celle qui correspond à vos besoins : + +| Image | Taille | Contenu | +| --- | --- | --- | +| `openpencil:latest` | ~226 Mo | Application web uniquement | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 Go | Tous les outils CLI | + +**Exécuter (web uniquement) :** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Exécuter avec un CLI IA (ex. Claude Code) :** + +Le chat IA repose sur la connexion OAuth de Claude CLI. Utilisez un volume Docker pour conserver la session de connexion : + +```bash +# Étape 1 — Connexion (une seule fois) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Étape 2 — Démarrer +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Compiler localement :** + +```bash +# Base (web uniquement) +docker build --target base -t openpencil . + +# Avec un CLI spécifique +docker build --target with-claude -t openpencil-claude . + +# Complet (tous les CLIs) +docker build --target full -t openpencil-full . +``` + ## Design natif IA **Du prompt à l'interface** diff --git a/README.hi.md b/README.hi.md index 305e54d5d..1e2713bea 100644 --- a/README.hi.md +++ b/README.hi.md @@ -102,6 +102,55 @@ bun run electron:dev > **पूर्वापेक्षाएँ:** [Bun](https://bun.sh/) >= 1.0 और [Node.js](https://nodejs.org/) >= 18 +### Docker डिप्लॉयमेंट + +कई इमेज वेरिएंट उपलब्ध हैं — अपनी ज़रूरत के अनुसार चुनें: + +| इमेज | आकार | शामिल | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | केवल वेब ऐप | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | सभी CLI टूल | + +**चलाएँ (केवल वेब):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**AI CLI के साथ चलाएँ (उदा. Claude Code):** + +AI चैट Claude CLI OAuth लॉगिन पर निर्भर करता है। लॉगिन सत्र को बनाए रखने के लिए Docker वॉल्यूम का उपयोग करें: + +```bash +# चरण 1 — लॉगिन (एक बार) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# चरण 2 — शुरू करें +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**स्थानीय रूप से बिल्ड करें:** + +```bash +# बेस (केवल वेब) +docker build --target base -t openpencil . + +# किसी विशिष्ट CLI के साथ +docker build --target with-claude -t openpencil-claude . + +# पूर्ण (सभी CLI) +docker build --target full -t openpencil-full . +``` + ## AI-नेटिव डिज़ाइन **प्रॉम्प्ट से UI तक** diff --git a/README.id.md b/README.id.md index b082d147c..4cc2ad015 100644 --- a/README.id.md +++ b/README.id.md @@ -102,6 +102,55 @@ bun run electron:dev > **Prasyarat:** [Bun](https://bun.sh/) >= 1.0 dan [Node.js](https://nodejs.org/) >= 18 +### Deployment Docker + +Tersedia beberapa varian image — pilih yang sesuai kebutuhan Anda: + +| Image | Ukuran | Termasuk | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Hanya aplikasi web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Semua alat CLI | + +**Jalankan (hanya web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Jalankan dengan AI CLI (misal Claude Code):** + +Chat AI bergantung pada login OAuth Claude CLI. Gunakan volume Docker untuk menyimpan sesi login: + +```bash +# Langkah 1 — Login (satu kali) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Langkah 2 — Mulai +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Build secara lokal:** + +```bash +# Dasar (hanya web) +docker build --target base -t openpencil . + +# Dengan CLI tertentu +docker build --target with-claude -t openpencil-claude . + +# Lengkap (semua CLI) +docker build --target full -t openpencil-full . +``` + ## Desain Berbasis AI **Dari Prompt ke UI** diff --git a/README.ja.md b/README.ja.md index abd1aa33a..7ced3be31 100644 --- a/README.ja.md +++ b/README.ja.md @@ -102,6 +102,55 @@ bun run electron:dev > **前提条件:** [Bun](https://bun.sh/) >= 1.0 および [Node.js](https://nodejs.org/) >= 18 +### Docker デプロイ + +複数のイメージバリアントが利用可能です — ニーズに合ったものを選択してください: + +| イメージ | サイズ | 含まれるもの | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Web アプリのみ | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | すべての CLI ツール | + +**実行(Web のみ):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**AI CLI 付きで実行(例:Claude Code):** + +AI チャットは Claude CLI OAuth ログインに依存しています。Docker ボリュームを使用してログインセッションを永続化してください: + +```bash +# ステップ 1 — ログイン(初回のみ) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# ステップ 2 — 起動 +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**ローカルビルド:** + +```bash +# ベース(Web のみ) +docker build --target base -t openpencil . + +# 特定の CLI 付き +docker build --target with-claude -t openpencil-claude . + +# フル(すべての CLI) +docker build --target full -t openpencil-full . +``` + ## AI ネイティブデザイン **プロンプトから UI へ** diff --git a/README.ko.md b/README.ko.md index e664c2856..886dc1b6a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -102,6 +102,55 @@ bun run electron:dev > **필수 조건:** [Bun](https://bun.sh/) >= 1.0 및 [Node.js](https://nodejs.org/) >= 18 +### Docker 배포 + +여러 이미지 변형을 사용할 수 있습니다 — 필요에 맞는 것을 선택하세요: + +| 이미지 | 크기 | 포함 내용 | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | 웹 앱만 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 | + +**실행 (웹만):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**AI CLI와 함께 실행 (예: Claude Code):** + +AI 채팅은 Claude CLI OAuth 로그인에 의존합니다. Docker 볼륨을 사용하여 로그인 세션을 유지하세요: + +```bash +# 1단계 — 로그인 (최초 1회) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# 2단계 — 시작 +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**로컬 빌드:** + +```bash +# 기본 (웹만) +docker build --target base -t openpencil . + +# 특정 CLI 포함 +docker build --target with-claude -t openpencil-claude . + +# 전체 (모든 CLI) +docker build --target full -t openpencil-full . +``` + ## AI 네이티브 디자인 **프롬프트에서 UI로** diff --git a/README.md b/README.md index 2cb957525..931ffc129 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,55 @@ bun run electron:dev > **Prerequisites:** [Bun](https://bun.sh/) >= 1.0 and [Node.js](https://nodejs.org/) >= 18 +### Docker + +Multiple image variants are available — pick the one that fits your needs: + +| Image | Size | Includes | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Web app only | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | All CLI tools | + +**Run (web only):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Run with AI CLI (e.g. Claude Code):** + +The AI chat relies on Claude CLI OAuth login. Use a Docker volume to persist the login session: + +```bash +# Step 1 — Login (one-time) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Step 2 — Start +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Build locally:** + +```bash +# Base (web only) +docker build --target base -t openpencil . + +# With a specific CLI +docker build --target with-claude -t openpencil-claude . + +# Full (all CLIs) +docker build --target full -t openpencil-full . +``` + ## AI-Native Design **Prompt to UI** diff --git a/README.pt.md b/README.pt.md index b84533152..cd6158b74 100644 --- a/README.pt.md +++ b/README.pt.md @@ -102,6 +102,55 @@ bun run electron:dev > **Pré-requisitos:** [Bun](https://bun.sh/) >= 1.0 e [Node.js](https://nodejs.org/) >= 18 +### Implantação com Docker + +Várias variantes de imagem estão disponíveis — escolha a que se adequa às suas necessidades: + +| Imagem | Tamanho | Inclui | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Apenas aplicação web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI | + +**Executar (apenas web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Executar com AI CLI (ex. Claude Code):** + +O chat de IA depende do login OAuth do Claude CLI. Use um volume Docker para persistir a sessão de login: + +```bash +# Passo 1 — Login (apenas uma vez) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Passo 2 — Iniciar +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Compilar localmente:** + +```bash +# Base (apenas web) +docker build --target base -t openpencil . + +# Com um CLI específico +docker build --target with-claude -t openpencil-claude . + +# Completo (todos os CLIs) +docker build --target full -t openpencil-full . +``` + ## Design Nativo com IA **Do Prompt à UI** diff --git a/README.ru.md b/README.ru.md index 05acf0d05..dd033f4c1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -102,6 +102,55 @@ bun run electron:dev > **Требования:** [Bun](https://bun.sh/) >= 1.0 и [Node.js](https://nodejs.org/) >= 18 +### Развёртывание через Docker + +Доступно несколько вариантов образов — выберите подходящий для ваших нужд: + +| Образ | Размер | Содержит | +| --- | --- | --- | +| `openpencil:latest` | ~226 МБ | Только веб-приложение | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты | + +**Запуск (только веб):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Запуск с AI CLI (например, Claude Code):** + +AI-чат использует OAuth-авторизацию Claude CLI. Используйте Docker-том для сохранения сессии авторизации: + +```bash +# Шаг 1 — Авторизация (однократно) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Шаг 2 — Запуск +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Локальная сборка:** + +```bash +# Базовый (только веб) +docker build --target base -t openpencil . + +# С конкретным CLI +docker build --target with-claude -t openpencil-claude . + +# Полный (все CLI) +docker build --target full -t openpencil-full . +``` + ## AI-нативный дизайн **От запроса к UI** diff --git a/README.th.md b/README.th.md index b141d3349..84ba3e24c 100644 --- a/README.th.md +++ b/README.th.md @@ -102,6 +102,55 @@ bun run electron:dev > **ข้อกำหนดเบื้องต้น:** [Bun](https://bun.sh/) >= 1.0 และ [Node.js](https://nodejs.org/) >= 18 +### การติดตั้งด้วย Docker + +มี image หลายรูปแบบให้เลือก — เลือกแบบที่เหมาะกับความต้องการของคุณ: + +| Image | ขนาด | รวม | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | เว็บแอปเท่านั้น | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด | + +**รัน (เว็บเท่านั้น):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**รันพร้อม AI CLI (เช่น Claude Code):** + +AI chat ต้องใช้การเข้าสู่ระบบ OAuth ของ Claude CLI ใช้ Docker volume เพื่อเก็บรักษา session การเข้าสู่ระบบ: + +```bash +# ขั้นตอนที่ 1 — เข้าสู่ระบบ (ครั้งเดียว) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# ขั้นตอนที่ 2 — เริ่มต้น +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Build ในเครื่อง:** + +```bash +# พื้นฐาน (เว็บเท่านั้น) +docker build --target base -t openpencil . + +# พร้อม CLI เฉพาะตัว +docker build --target with-claude -t openpencil-claude . + +# เต็มรูปแบบ (CLI ทั้งหมด) +docker build --target full -t openpencil-full . +``` + ## การออกแบบที่ขับเคลื่อนด้วย AI **จาก Prompt สู่ UI** diff --git a/README.tr.md b/README.tr.md index 672fa1d14..6482d9c7a 100644 --- a/README.tr.md +++ b/README.tr.md @@ -102,6 +102,55 @@ bun run electron:dev > **Ön koşullar:** [Bun](https://bun.sh/) >= 1.0 ve [Node.js](https://nodejs.org/) >= 18 +### Docker ile Dağıtım + +Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı seçin: + +| Görüntü | Boyut | İçerik | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Yalnızca web uygulaması | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Tüm CLI araçları | + +**Çalıştır (yalnızca web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**AI CLI ile çalıştır (ör. Claude Code):** + +AI sohbeti Claude CLI OAuth girişine bağlıdır. Giriş oturumunu kalıcı hale getirmek için bir Docker hacmi kullanın: + +```bash +# Adım 1 — Giriş (tek seferlik) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Adım 2 — Başlat +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Yerel olarak derle:** + +```bash +# Temel (yalnızca web) +docker build --target base -t openpencil . + +# Belirli bir CLI ile +docker build --target with-claude -t openpencil-claude . + +# Tam (tüm CLI'lar) +docker build --target full -t openpencil-full . +``` + ## AI Destekli Tasarım **Prompttan UI'ye** diff --git a/README.vi.md b/README.vi.md index a02d96eda..e5f35af41 100644 --- a/README.vi.md +++ b/README.vi.md @@ -102,6 +102,55 @@ bun run electron:dev > **Yêu cầu:** [Bun](https://bun.sh/) >= 1.0 và [Node.js](https://nodejs.org/) >= 18 +### Triển khai bằng Docker + +Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu cầu của bạn: + +| Image | Kích thước | Bao gồm | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | Chỉ ứng dụng web | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI | + +**Chạy (chỉ web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**Chạy với AI CLI (ví dụ Claude Code):** + +Chat AI dựa vào đăng nhập OAuth của Claude CLI. Sử dụng Docker volume để lưu phiên đăng nhập: + +```bash +# Bước 1 — Đăng nhập (một lần) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# Bước 2 — Khởi động +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**Build cục bộ:** + +```bash +# Cơ bản (chỉ web) +docker build --target base -t openpencil . + +# Với một CLI cụ thể +docker build --target with-claude -t openpencil-claude . + +# Đầy đủ (tất cả CLI) +docker build --target full -t openpencil-full . +``` + ## Thiết kế thuần AI **Từ Prompt đến Giao diện** diff --git a/README.zh-TW.md b/README.zh-TW.md index 9fafe8efb..e1d3b49fa 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -102,6 +102,55 @@ bun run electron:dev > **前置條件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18 +### Docker 部署 + +提供多種映像檔變體 — 選擇適合您需求的版本: + +| 映像檔 | 大小 | 包含 | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | 僅 Web 應用程式 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 | + +**執行(僅 Web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**搭配 AI CLI 執行(例如 Claude Code):** + +AI 聊天功能依賴 Claude CLI OAuth 登入。使用 Docker volume 來保留登入狀態: + +```bash +# 步驟 1 — 登入(僅需一次) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# 步驟 2 — 啟動 +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**本地建置:** + +```bash +# 基礎(僅 Web) +docker build --target base -t openpencil . + +# 搭配特定 CLI +docker build --target with-claude -t openpencil-claude . + +# 完整版(所有 CLI) +docker build --target full -t openpencil-full . +``` + ## AI 原生設計 **提示詞生成 UI** diff --git a/README.zh.md b/README.zh.md index 26a81c39d..4b0eb6010 100644 --- a/README.zh.md +++ b/README.zh.md @@ -102,6 +102,55 @@ bun run electron:dev > **前置条件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18 +### Docker 部署 + +提供多个镜像变体,按需选择: + +| 镜像 | 大小 | 包含 | +| --- | --- | --- | +| `openpencil:latest` | ~226 MB | 仅 Web 应用 | +| `openpencil-claude:latest` | — | + Claude Code CLI | +| `openpencil-codex:latest` | — | + Codex CLI | +| `openpencil-opencode:latest` | — | + OpenCode CLI | +| `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 | + +**运行(仅 Web):** + +```bash +docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest +``` + +**运行 AI CLI(以 Claude Code 为例):** + +AI 聊天依赖 Claude CLI 的 OAuth 登录,使用 Docker volume 持久化登录状态: + +```bash +# 第一步 — 登录(仅需一次) +docker volume create openpencil-claude-auth +docker run -it --rm \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest claude login + +# 第二步 — 启动 +docker run -d -p 3000:3000 \ + -v openpencil-claude-auth:/root/.claude \ + ghcr.io/zseven-w/openpencil-claude:latest +``` + +**本地构建:** + +```bash +# 基础版(仅 Web) +docker build --target base -t openpencil . + +# 指定 CLI +docker build --target with-claude -t openpencil-claude . + +# 完整版(全部 CLI) +docker build --target full -t openpencil-full . +``` + ## AI 原生设计 **提示词生成 UI** diff --git a/server/api/ai/connect-agent.ts b/server/api/ai/connect-agent.ts index 688278113..43e377e8f 100644 --- a/server/api/ai/connect-agent.ts +++ b/server/api/ai/connect-agent.ts @@ -197,13 +197,71 @@ async function connectCodexCli(): Promise { } } +/** Resolve the opencode binary path, checking PATH then common install locations. */ +async function resolveOpencodeBinary(): Promise { + const { execSync } = await import('node:child_process') + const { existsSync } = await import('node:fs') + const { homedir } = await import('node:os') + const { join } = await import('node:path') + const isWin = process.platform === 'win32' + + // 1. Try PATH lookup + try { + const cmd = isWin ? 'where opencode' : 'which opencode 2>/dev/null' + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim().split(/\r?\n/)[0]?.trim() + if (result && existsSync(result)) return result + } catch { /* not in PATH */ } + + // 2. Try `npm prefix -g` to find actual npm global bin directory + // On Windows, must use `npm.cmd` since Electron spawns cmd.exe + try { + const npmCmd = isWin ? 'npm.cmd prefix -g' : 'npm prefix -g' + const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim() + if (prefix) { + const bin = isWin ? join(prefix, 'opencode.cmd') : join(prefix, 'bin', 'opencode') + if (existsSync(bin)) return bin + } + } catch { /* npm not available */ } + + // 3. Common install locations + // npm -g → %APPDATA%\npm (Windows), /usr/local (macOS/Linux) + // curl installer → ~/.opencode/bin (macOS/Linux) + // Homebrew → /usr/local/bin or /opt/homebrew/bin (macOS) + const home = homedir() + const candidates = isWin + ? [ + // npm global + join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), + join(process.env.ProgramFiles || '', 'nodejs', 'opencode.cmd'), + // nvm-windows / fnm + join(process.env.NVM_SYMLINK || '', 'opencode.cmd'), + join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'), + // Scoop + join(home, 'scoop', 'shims', 'opencode.exe'), + join(process.env.LOCALAPPDATA || '', 'Programs', 'opencode', 'opencode.exe'), + ] + : [ + // curl installer (https://opencode.ai/install) + join(home, '.opencode', 'bin', 'opencode'), + // npm global + join(home, '.npm-global', 'bin', 'opencode'), + '/usr/local/bin/opencode', + // Homebrew + '/opt/homebrew/bin/opencode', + join(home, '.local', 'bin', 'opencode'), + ] + for (const c of candidates) { + if (c && existsSync(c)) return c + } + + return undefined +} + /** Connect to OpenCode and fetch its configured providers/models. */ async function connectOpenCode(): Promise { try { - const { execSync } = await import('node:child_process') - const whichCmd = process.platform === 'win32' ? 'where opencode 2>nul' : 'which opencode 2>/dev/null || echo ""' - const whichResult = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000 }).trim() - if (!whichResult) { + const binaryPath = await resolveOpencodeBinary() + if (!binaryPath) { return { connected: false, models: [], notInstalled: true, error: 'OpenCode CLI not found' } } diff --git a/server/api/ai/install-agent.ts b/server/api/ai/install-agent.ts index 94e394ff5..e9ddbeb3f 100644 --- a/server/api/ai/install-agent.ts +++ b/server/api/ai/install-agent.ts @@ -52,7 +52,7 @@ function getInstallInfo(agent: string): { command: string; docsUrl: string } { case 'opencode': return { command: isWin - ? 'go install github.com/opencode-ai/opencode@latest' + ? 'npm install -g opencode-ai' : 'curl -fsSL https://opencode.ai/install | bash', docsUrl: 'https://opencode.ai', } @@ -147,12 +147,13 @@ async function tryNpmInstall(pkg: string, binary: string): Promise { - if (process.platform === 'win32') { - return { success: false, error: 'Auto-install not supported on Windows' } - } + const isWin = process.platform === 'win32' + const cmd = isWin + ? 'npm.cmd install -g opencode-ai' + : 'curl -fsSL https://opencode.ai/install | bash' try { - execSync('curl -fsSL https://opencode.ai/install | bash', { + execSync(cmd, { encoding: 'utf-8', timeout: 120_000, stdio: 'pipe', diff --git a/src/canvas/canvas-node-creator.ts b/src/canvas/canvas-node-creator.ts index b25d1e02d..14aac62ed 100644 --- a/src/canvas/canvas-node-creator.ts +++ b/src/canvas/canvas-node-creator.ts @@ -61,6 +61,22 @@ export function createNodeForTool( fill: [{ type: 'solid', color: DEFAULT_STROKE }], }, } + case 'polygon': + return { + id, + type: 'polygon', + name: 'Polygon', + x, + y, + width: Math.abs(width), + height: Math.abs(height), + polygonCount: 3, + fill: [{ type: 'solid', color: DEFAULT_FILL }], + stroke: { + thickness: DEFAULT_STROKE_WIDTH, + fill: [{ type: 'solid', color: DEFAULT_STROKE }], + }, + } case 'line': return { id, diff --git a/src/canvas/skia/skia-canvas.tsx b/src/canvas/skia/skia-canvas.tsx index e3729f6b2..1fbe44f4c 100644 --- a/src/canvas/skia/skia-canvas.tsx +++ b/src/canvas/skia/skia-canvas.tsx @@ -8,7 +8,8 @@ import { inferLayout } from '../canvas-layout-engine' import { SkiaPenTool } from './skia-pen-tool' import { setSkiaEngineRef } from '../skia-engine-ref' import type { ToolType } from '@/types/canvas' -import type { PenNode, ContainerProps, TextNode } from '@/types/pen' +import type { PenNode, ContainerProps, TextNode, EllipseNode } from '@/types/pen' +import { computeArcHandles } from './skia-overlays' interface TextEditState { nodeId: string @@ -207,6 +208,37 @@ export default function SkiaCanvas() { let rotateCenterY = 0 let rotateStartAngle = 0 + // --- Arc handle state --- + type ArcHandleType = 'start' | 'end' | 'inner' + let isDraggingArc = false + let arcHandleType: ArcHandleType | null = null + let arcNodeId: string | null = null + + const ARC_HANDLE_HIT_RADIUS = 8 + + /** Check if a scene point hits an arc handle of the selected ellipse. */ + const hitTestArcHandle = (sceneX: number, sceneY: number): { type: ArcHandleType; nodeId: string } | null => { + const engine = getEngine() + if (!engine) return null + const { selectedIds } = useCanvasStore.getState().selection + if (selectedIds.length !== 1) return null + const rn = engine.spatialIndex.get(selectedIds[0]) + if (!rn || rn.node.type !== 'ellipse') return null + const eNode = rn.node as EllipseNode + const handles = computeArcHandles( + rn.absX, rn.absY, rn.absW, rn.absH, + eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0, + ) + const hitR = ARC_HANDLE_HIT_RADIUS / engine.zoom + for (const key of ['start', 'end', 'inner'] as ArcHandleType[]) { + const h = handles[key] + if (Math.hypot(sceneX - h.x, sceneY - h.y) <= hitR) { + return { type: key, nodeId: rn.node.id } + } + } + return null + } + // --- Drawing tool state --- let isDrawing = false let drawTool: ToolType = 'select' @@ -356,7 +388,7 @@ export default function SkiaCanvas() { drawStartX = scene.x drawStartY = scene.y engine.previewShape = { - type: tool as 'rectangle' | 'ellipse' | 'frame' | 'line', + type: tool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon', x: scene.x, y: scene.y, w: 0, h: 0, } engine.markDirty() @@ -365,6 +397,16 @@ export default function SkiaCanvas() { // --- Select tool --- if (tool === 'select') { + // Check arc handles first (ellipse arc editing) + const arcHit = hitTestArcHandle(scene.x, scene.y) + if (arcHit) { + isDraggingArc = true + arcHandleType = arcHit.type + arcNodeId = arcHit.nodeId + canvasEl.style.cursor = 'pointer' + return + } + // Check resize handle first (only for single selection) const handleHit = hitTestHandle(scene.x, scene.y) if (handleHit) { @@ -547,6 +589,46 @@ export default function SkiaCanvas() { return } + // --- Arc handle drag --- + if (isDraggingArc && arcNodeId && arcHandleType) { + const rn = engine.spatialIndex.get(arcNodeId) + if (rn) { + const cx = rn.absX + rn.absW / 2 + const cy = rn.absY + rn.absH / 2 + // Compute angle from center, accounting for ellipse aspect ratio + const angle = Math.atan2(scene.y - cy, scene.x - cx) * 180 / Math.PI + const normalizedAngle = ((angle % 360) + 360) % 360 + const eNode = rn.node as EllipseNode + + if (arcHandleType === 'start') { + const oldStart = eNode.startAngle ?? 0 + const oldEnd = oldStart + (eNode.sweepAngle ?? 360) + // Keep end angle fixed, adjust sweep + const newSweep = ((oldEnd - normalizedAngle) % 360 + 360) % 360 + useDocumentStore.getState().updateNode(arcNodeId, { + startAngle: normalizedAngle, + sweepAngle: newSweep || 360, + } as Partial) + } else if (arcHandleType === 'end') { + const startA = eNode.startAngle ?? 0 + const newSweep = ((normalizedAngle - startA) % 360 + 360) % 360 + useDocumentStore.getState().updateNode(arcNodeId, { + sweepAngle: newSweep || 360, + } as Partial) + } else if (arcHandleType === 'inner') { + // Inner radius: distance from center as ratio of outer radius + const rx = rn.absW / 2 + const ry = rn.absH / 2 + const dist = Math.hypot((scene.x - cx) / rx, (scene.y - cy) / ry) + const newInner = Math.max(0, Math.min(0.99, dist)) + useDocumentStore.getState().updateNode(arcNodeId, { + innerRadius: newInner, + } as Partial) + } + } + return + } + // --- Drawing tool preview --- if (isDrawing && engine.previewShape) { const dx = scene.x - drawStartX @@ -561,7 +643,7 @@ export default function SkiaCanvas() { } else { // Rectangle / ellipse / frame: handle negative drag direction engine.previewShape = { - type: drawTool as 'rectangle' | 'ellipse' | 'frame' | 'line', + type: drawTool as 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon', x: dx < 0 ? scene.x : drawStartX, y: dy < 0 ? scene.y : drawStartY, w: Math.abs(dx), @@ -636,6 +718,12 @@ export default function SkiaCanvas() { // --- Hover + handle cursor (select tool only) --- if (getTool() === 'select' && !spacePressed) { + // Check arc handle hover + const arcHoverHit = hitTestArcHandle(scene.x, scene.y) + if (arcHoverHit) { + canvasEl.style.cursor = 'pointer' + return + } // Check handle hover for cursor const handleHit = hitTestHandle(scene.x, scene.y) if (handleHit) { @@ -679,6 +767,14 @@ export default function SkiaCanvas() { canvasEl.style.cursor = toolToCursor(getTool()) } + // --- Arc handle end --- + if (isDraggingArc) { + isDraggingArc = false + arcHandleType = null + arcNodeId = null + canvasEl.style.cursor = toolToCursor(getTool()) + } + // --- Rotation end --- if (isRotating) { isRotating = false diff --git a/src/canvas/skia/skia-engine.ts b/src/canvas/skia/skia-engine.ts index 1968c7882..229aa8cd7 100644 --- a/src/canvas/skia/skia-engine.ts +++ b/src/canvas/skia/skia-engine.ts @@ -1,5 +1,5 @@ import type { CanvasKit, Surface } from 'canvaskit-wasm' -import type { PenNode, ContainerProps } from '@/types/pen' +import type { PenNode, ContainerProps, EllipseNode } from '@/types/pen' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, getActivePageChildren, getAllChildren } from '@/stores/document-store' import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables' @@ -399,7 +399,7 @@ export class SkiaEngine { hoveredNodeId: string | null = null marquee: { x1: number; y1: number; x2: number; y2: number } | null = null previewShape: { - type: 'rectangle' | 'ellipse' | 'frame' | 'line' + type: 'rectangle' | 'ellipse' | 'frame' | 'line' | 'polygon' x: number; y: number; w: number; h: number } | null = null penPreview: import('./skia-overlays').PenPreviewData | null = null @@ -644,6 +644,21 @@ export class SkiaEngine { } } + // Arc handles for selected ellipse + if (selectedIds.size === 1) { + const selId = selectedIds.values().next().value as string + const selRN = this.spatialIndex.get(selId) + if (selRN && selRN.node.type === 'ellipse') { + const eNode = selRN.node as EllipseNode + this.renderer.drawArcHandles( + canvas, + selRN.absX, selRN.absY, selRN.absW, selRN.absH, + eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0, + this.zoom, + ) + } + } + // Drawing preview shape if (this.previewShape) { this.renderer.drawPreview(canvas, this.previewShape) diff --git a/src/canvas/skia/skia-overlays.ts b/src/canvas/skia/skia-overlays.ts index 7b103835a..9782672cc 100644 --- a/src/canvas/skia/skia-overlays.ts +++ b/src/canvas/skia/skia-overlays.ts @@ -180,6 +180,90 @@ export function drawSelectionBorder( handleStrokePaint.delete() } +/** + * Arc handle positions for an ellipse node (scene coordinates). + */ +export interface ArcHandlePositions { + start: { x: number; y: number } + end: { x: number; y: number } + inner: { x: number; y: number } +} + +/** + * Compute arc handle positions in scene coordinates. + */ +export function computeArcHandles( + x: number, y: number, w: number, h: number, + startAngle: number, sweepAngle: number, innerRadius: number, +): ArcHandlePositions { + const cx = x + w / 2 + const cy = y + h / 2 + const rx = w / 2 + const ry = h / 2 + const startRad = (startAngle * Math.PI) / 180 + const endRad = ((startAngle + sweepAngle) * Math.PI) / 180 + // Mid-angle for inner radius handle + const midRad = (startRad + endRad) / 2 + + return { + start: { + x: cx + rx * Math.cos(startRad), + y: cy + ry * Math.sin(startRad), + }, + end: { + x: cx + rx * Math.cos(endRad), + y: cy + ry * Math.sin(endRad), + }, + inner: { + x: cx + rx * innerRadius * Math.cos(midRad), + y: cy + ry * innerRadius * Math.sin(midRad), + }, + } +} + +/** + * Draw arc control handles on a selected ellipse. + */ +export function drawArcHandles( + ck: CanvasKit, canvas: Canvas, + x: number, y: number, w: number, h: number, + startAngle: number, sweepAngle: number, innerRadius: number, + zoom: number, +) { + const invZ = 1 / zoom + const handles = computeArcHandles(x, y, w, h, startAngle, sweepAngle, innerRadius) + + const fillPaint = new ck.Paint() + fillPaint.setStyle(ck.PaintStyle.Fill) + fillPaint.setAntiAlias(true) + fillPaint.setColor(ck.WHITE) + + const strokePaint = new ck.Paint() + strokePaint.setStyle(ck.PaintStyle.Stroke) + strokePaint.setAntiAlias(true) + strokePaint.setStrokeWidth(1.5 * invZ) + strokePaint.setColor(parseColor(ck, SELECTION_BLUE)) + + const r = 4 * invZ + + // Start handle + canvas.drawCircle(handles.start.x, handles.start.y, r, fillPaint) + canvas.drawCircle(handles.start.x, handles.start.y, r, strokePaint) + + // End handle + canvas.drawCircle(handles.end.x, handles.end.y, r, fillPaint) + canvas.drawCircle(handles.end.x, handles.end.y, r, strokePaint) + + // Inner radius handle (always visible) + { + canvas.drawCircle(handles.inner.x, handles.inner.y, r, fillPaint) + canvas.drawCircle(handles.inner.x, handles.inner.y, r, strokePaint) + } + + fillPaint.delete() + strokePaint.delete() +} + /** * Draw a frame label above a frame. */ diff --git a/src/canvas/skia/skia-renderer.ts b/src/canvas/skia/skia-renderer.ts index 331761431..ccc180909 100644 --- a/src/canvas/skia/skia-renderer.ts +++ b/src/canvas/skia/skia-renderer.ts @@ -30,6 +30,7 @@ import { drawAgentBadge as _drawAgentBadge, drawAgentNodeBorder as _drawAgentNodeBorder, drawAgentPreviewFill as _drawAgentPreviewFill, + drawArcHandles as _drawArcHandles, type PenPreviewData, } from './skia-overlays' @@ -666,6 +667,7 @@ export class SkiaRenderer { const eNode = node as EllipseNode const fills = eNode.fill const stroke = eNode.stroke + const cr = cornerRadiusValue(eNode.cornerRadius) if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) { const arcD = buildEllipseArcPath(w, h, eNode.startAngle ?? 0, eNode.sweepAngle ?? 360, eNode.innerRadius ?? 0) @@ -674,8 +676,22 @@ export class SkiaRenderer { path.offset(x, y) const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) fillPaint.setAntiAlias(true) + if (cr > 0) { + const effect = ck.PathEffect.MakeCorner(cr) + if (effect) fillPaint.setPathEffect(effect) + } canvas.drawPath(path, fillPaint) fillPaint.delete() + + const strokePaint = this.makeStrokePaint(stroke, opacity) + if (strokePaint) { + if (cr > 0) { + const effect = ck.PathEffect.MakeCorner(cr) + if (effect) strokePaint.setPathEffect(effect) + } + canvas.drawPath(path, strokePaint) + strokePaint.delete() + } path.delete() } return @@ -726,23 +742,47 @@ export class SkiaRenderer { const count = pNode.polygonCount || 6 const fills = pNode.fill const stroke = pNode.stroke + const cr = cornerRadiusValue(pNode.cornerRadius) + + // Compute unit polygon vertices, then scale to fill bounding box + const raw: [number, number][] = [] + for (let i = 0; i < count; i++) { + const angle = (i * 2 * Math.PI) / count - Math.PI / 2 + raw.push([Math.cos(angle), Math.sin(angle)]) + } + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (const [rx, ry] of raw) { + if (rx < minX) minX = rx + if (rx > maxX) maxX = rx + if (ry < minY) minY = ry + if (ry > maxY) maxY = ry + } + const rawW = maxX - minX + const rawH = maxY - minY const path = new ck.Path() for (let i = 0; i < count; i++) { - const angle = (i * 2 * Math.PI) / count - Math.PI / 2 - const px = x + (w / 2) * Math.cos(angle) + w / 2 - const py = y + (h / 2) * Math.sin(angle) + h / 2 + const px = x + ((raw[i][0] - minX) / rawW) * w + const py = y + ((raw[i][1] - minY) / rawH) * h if (i === 0) path.moveTo(px, py) else path.lineTo(px, py) } path.close() const { paint: fillPaint } = this.makeFillPaint(fills, w, h, opacity, x, y) + if (cr > 0) { + const effect = ck.PathEffect.MakeCorner(cr) + if (effect) fillPaint.setPathEffect(effect) + } canvas.drawPath(path, fillPaint) fillPaint.delete() const strokePaint = this.makeStrokePaint(stroke, opacity) if (strokePaint) { + if (cr > 0) { + const effect = ck.PathEffect.MakeCorner(cr) + if (effect) strokePaint.setPathEffect(effect) + } canvas.drawPath(path, strokePaint) strokePaint.delete() } @@ -1481,6 +1521,33 @@ export class SkiaRenderer { } else if (shape.type === 'ellipse') { canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint) canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint) + } else if (shape.type === 'polygon') { + const count = 3 + const raw: [number, number][] = [] + for (let i = 0; i < count; i++) { + const angle = (i * 2 * Math.PI) / count - Math.PI / 2 + raw.push([Math.cos(angle), Math.sin(angle)]) + } + let pMinX = Infinity, pMaxX = -Infinity, pMinY = Infinity, pMaxY = -Infinity + for (const [rx, ry] of raw) { + if (rx < pMinX) pMinX = rx + if (rx > pMaxX) pMaxX = rx + if (ry < pMinY) pMinY = ry + if (ry > pMaxY) pMaxY = ry + } + const rw = pMaxX - pMinX + const rh = pMaxY - pMinY + const path = new ck.Path() + for (let i = 0; i < count; i++) { + const px = x + ((raw[i][0] - pMinX) / rw) * w + const py = y + ((raw[i][1] - pMinY) / rh) * h + if (i === 0) path.moveTo(px, py) + else path.lineTo(px, py) + } + path.close() + canvas.drawPath(path, fillPaint) + canvas.drawPath(path, strokePaint) + path.delete() } else { // rectangle / frame canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint) @@ -1552,4 +1619,13 @@ export class SkiaRenderer { ) { _drawAgentPreviewFill(this.ck, canvas, x, y, w, h, color, time) } + + drawArcHandles( + canvas: Canvas, + x: number, y: number, w: number, h: number, + startAngle: number, sweepAngle: number, innerRadius: number, + zoom: number, + ) { + _drawArcHandles(this.ck, canvas, x, y, w, h, startAngle, sweepAngle, innerRadius, zoom) + } } diff --git a/src/components/editor/shape-tool-dropdown.tsx b/src/components/editor/shape-tool-dropdown.tsx index da80a1158..0fff95200 100644 --- a/src/components/editor/shape-tool-dropdown.tsx +++ b/src/components/editor/shape-tool-dropdown.tsx @@ -3,6 +3,7 @@ import { Square, Circle, Minus, + Triangle, PenTool, Sparkles, ImagePlus, @@ -17,7 +18,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' -const SHAPE_TOOLS: ToolType[] = ['rectangle', 'ellipse', 'line', 'path'] +const SHAPE_TOOLS: ToolType[] = ['rectangle', 'ellipse', 'polygon', 'line', 'path'] interface ToolItem { type: 'tool' @@ -44,6 +45,7 @@ interface ShapeToolDropdownProps { const TOOL_ICON_MAP: Record = { rectangle: , ellipse: , + polygon: , line: , path: , } @@ -90,6 +92,7 @@ export default function ShapeToolDropdown({ const items: DropdownItem[] = [ { type: 'tool', tool: 'rectangle', icon: , label: t('shapes.rectangle') }, { type: 'tool', tool: 'ellipse', icon: , label: t('shapes.ellipse') }, + { type: 'tool', tool: 'polygon', icon: , label: t('shapes.polygon') }, { type: 'tool', tool: 'line', icon: , label: t('shapes.line') }, { type: 'action', key: 'icon', icon: , label: t('shapes.icon'), onAction: onIconPickerOpen }, { type: 'action', key: 'image', icon: , label: t('shapes.importImageSvg'), onAction: onImageImport }, diff --git a/src/components/panels/appearance-section.tsx b/src/components/panels/appearance-section.tsx index b5d2e1486..52c7721a4 100644 --- a/src/components/panels/appearance-section.tsx +++ b/src/components/panels/appearance-section.tsx @@ -3,7 +3,7 @@ import NumberInput from '@/components/shared/number-input' import SectionHeader from '@/components/shared/section-header' import VariablePicker from '@/components/shared/variable-picker' import { isVariableRef } from '@/variables/resolve-variables' -import type { PenNode } from '@/types/pen' +import type { PenNode, PolygonNode, EllipseNode } from '@/types/pen' interface AppearanceSectionProps { node: PenNode @@ -18,34 +18,77 @@ export default function AppearanceSection({ const rawOpacity = node.opacity const isBound = typeof rawOpacity === 'string' && isVariableRef(rawOpacity) const opacity = typeof rawOpacity === 'number' ? rawOpacity * 100 : 100 + const isPolygon = node.type === 'polygon' + const isEllipse = node.type === 'ellipse' return (
-
-
- {isBound ? ( -
- {rawOpacity} -
- ) : ( +
+
+
+ {isBound ? ( +
+ {rawOpacity} +
+ ) : ( + onUpdate({ opacity: v / 100 })} + min={0} + max={100} + suffix="%" + /> + )} +
+ onUpdate({ opacity: ref as unknown as number })} + onUnbind={(val) => onUpdate({ opacity: Number(val) })} + /> +
+ {isPolygon && ( + onUpdate({ polygonCount: v } as Partial)} + min={3} + max={100} + /> + )} +
+ {isEllipse && ( + <> +
onUpdate({ opacity: v / 100 })} + label={t('ellipse.start')} + value={Math.round((node as EllipseNode).startAngle ?? 0)} + onChange={(v) => onUpdate({ startAngle: v } as Partial)} min={0} - max={100} + max={360} + suffix="°" + /> + onUpdate({ sweepAngle: v } as Partial)} + min={0} + max={360} + suffix="°" + /> + onUpdate({ innerRadius: v / 100 } as Partial)} + min={0} + max={99} suffix="%" /> - )} -
- onUpdate({ opacity: ref as unknown as number })} - onUnbind={(val) => onUpdate({ opacity: Number(val) })} - /> -
+
+ + )}
) } diff --git a/src/components/panels/layer-item.tsx b/src/components/panels/layer-item.tsx index f549d5e18..8fa380447 100644 --- a/src/components/panels/layer-item.tsx +++ b/src/components/panels/layer-item.tsx @@ -10,7 +10,7 @@ import { Lock, Unlock, FolderOpen, - Hexagon, + Triangle, Spline, Link, ImageIcon, @@ -28,7 +28,7 @@ const TYPE_ICONS: Record = { line: Minus, frame: Frame, group: FolderOpen, - polygon: Hexagon, + polygon: Triangle, path: Spline, image: ImageIcon, icon_font: Smile, diff --git a/src/components/panels/property-panel.tsx b/src/components/panels/property-panel.tsx index 07de95564..17824c1b5 100644 --- a/src/components/panels/property-panel.tsx +++ b/src/components/panels/property-panel.tsx @@ -122,6 +122,7 @@ export default function PropertyPanel({ embedded }: { embedded?: boolean } = {}) const hasStroke = !isImage const hasCornerRadius = displayNode.type === 'rectangle' || displayNode.type === 'frame' || isImage + || displayNode.type === 'polygon' || displayNode.type === 'ellipse' const hasEffects = true const isText = displayNode.type === 'text' const isIcon = (displayNode.type === 'path' && !!(displayNode as PathNode).iconId) diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 5b9940756..68bacd6ba 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -23,6 +23,7 @@ const TOOL_KEYS: Record = { f: 'frame', r: 'rectangle', o: 'ellipse', + y: 'polygon', l: 'line', t: 'text', p: 'path', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 6dcb45f80..f5f3e31af 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -30,6 +30,7 @@ const de: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Rechteck', 'shapes.ellipse': 'Ellipse', + 'shapes.polygon': 'Polygon', 'shapes.line': 'Linie', 'shapes.icon': 'Symbol', 'shapes.importImageSvg': 'Bild oder SVG importieren\u2026', @@ -240,6 +241,14 @@ const de: TranslationKeys = { 'export.exportFormat': '{{format}} exportieren', 'export.exportLayer': 'Ebene exportieren', + // ── Polygon ── + 'polygon.sides': 'Seiten', + + // ── Ellipse ── + 'ellipse.start': 'Start', + 'ellipse.sweep': 'Bogen', + 'ellipse.innerRadius': 'Innen', + // ── Corner Radius ── 'cornerRadius.title': 'Eckenradius', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9bac2da5e..82c7ecca9 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -28,6 +28,7 @@ const en = { // ── Shapes ── 'shapes.rectangle': 'Rectangle', 'shapes.ellipse': 'Ellipse', + 'shapes.polygon': 'Polygon', 'shapes.line': 'Line', 'shapes.icon': 'Icon', 'shapes.importImageSvg': 'Import Image or SVG\u2026', @@ -236,6 +237,14 @@ const en = { 'export.exportFormat': 'Export {{format}}', 'export.exportLayer': 'Export layer', + // ── Polygon ── + 'polygon.sides': 'Sides', + + // ── Ellipse ── + 'ellipse.start': 'Start', + 'ellipse.sweep': 'Sweep', + 'ellipse.innerRadius': 'Inner', + // ── Corner Radius ── 'cornerRadius.title': 'Corner Radius', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 8f80c0a79..b46fc4cf4 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -30,6 +30,7 @@ const es: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Rectángulo', 'shapes.ellipse': 'Elipse', + 'shapes.polygon': 'Polígono', 'shapes.line': 'Línea', 'shapes.icon': 'Icono', 'shapes.importImageSvg': 'Importar imagen o SVG\u2026', @@ -241,6 +242,14 @@ const es: TranslationKeys = { 'export.exportFormat': 'Exportar {{format}}', 'export.exportLayer': 'Exportar capa', + // ── Polygon ── + 'polygon.sides': 'Lados', + + // ── Ellipse ── + 'ellipse.start': 'Inicio', + 'ellipse.sweep': 'Barrido', + 'ellipse.innerRadius': 'Interior', + // ── Corner Radius ── 'cornerRadius.title': 'Radio de esquina', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index ba4def076..e7d6dd15b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -30,6 +30,7 @@ const fr: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Rectangle', 'shapes.ellipse': 'Ellipse', + 'shapes.polygon': 'Polygone', 'shapes.line': 'Ligne', 'shapes.icon': 'Icône', 'shapes.importImageSvg': 'Importer une image ou un SVG\u2026', @@ -241,6 +242,14 @@ const fr: TranslationKeys = { 'export.exportFormat': 'Exporter en {{format}}', 'export.exportLayer': 'Exporter le calque', + // ── Polygon ── + 'polygon.sides': 'Côtés', + + // ── Ellipse ── + 'ellipse.start': 'Début', + 'ellipse.sweep': 'Balayage', + 'ellipse.innerRadius': 'Intérieur', + // ── Corner Radius ── 'cornerRadius.title': 'Rayon de coin', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index db5acd2e0..a2ffc5bcf 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -30,6 +30,7 @@ const hi: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'आयत', 'shapes.ellipse': 'दीर्घवृत्त', + 'shapes.polygon': 'बहुभुज', 'shapes.line': 'रेखा', 'shapes.icon': 'आइकन', 'shapes.importImageSvg': 'इमेज या SVG आयात करें\u2026', @@ -238,6 +239,14 @@ const hi: TranslationKeys = { 'export.exportFormat': '{{format}} निर्यात करें', 'export.exportLayer': 'लेयर निर्यात करें', + // ── Polygon ── + 'polygon.sides': 'भुजाएँ', + + // ── Ellipse ── + 'ellipse.start': 'शुरू', + 'ellipse.sweep': 'स्वीप', + 'ellipse.innerRadius': 'आंतरिक', + // ── Corner Radius ── 'cornerRadius.title': 'कोने की गोलाई', diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts index 7c6a13c82..6194736ba 100644 --- a/src/i18n/locales/id.ts +++ b/src/i18n/locales/id.ts @@ -30,6 +30,7 @@ const id: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Persegi Panjang', 'shapes.ellipse': 'Elips', + 'shapes.polygon': 'Poligon', 'shapes.line': 'Garis', 'shapes.icon': 'Ikon', 'shapes.importImageSvg': 'Impor Gambar atau SVG\u2026', @@ -238,6 +239,14 @@ const id: TranslationKeys = { 'export.exportFormat': 'Ekspor {{format}}', 'export.exportLayer': 'Ekspor layer', + // ── Polygon ── + 'polygon.sides': 'Sisi', + + // ── Ellipse ── + 'ellipse.start': 'Mulai', + 'ellipse.sweep': 'Sapuan', + 'ellipse.innerRadius': 'Dalam', + // ── Corner Radius ── 'cornerRadius.title': 'Radius Sudut', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index 555629a9b..c9ef997d6 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -30,6 +30,7 @@ const ja: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': '長方形', 'shapes.ellipse': '楕円', + 'shapes.polygon': 'ポリゴン', 'shapes.line': '線', 'shapes.icon': 'アイコン', 'shapes.importImageSvg': '画像または SVG をインポート\u2026', @@ -242,6 +243,14 @@ const ja: TranslationKeys = { 'export.exportFormat': '{{format}} をエクスポート', 'export.exportLayer': 'レイヤーをエクスポート', + // ── Polygon ── + 'polygon.sides': '辺の数', + + // ── Ellipse ── + 'ellipse.start': '開始', + 'ellipse.sweep': 'スイープ', + 'ellipse.innerRadius': '内径', + // ── Corner Radius ── 'cornerRadius.title': '角丸', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index 6a7f72eda..9e7114f77 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -30,6 +30,7 @@ const ko: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': '사각형', 'shapes.ellipse': '타원', + 'shapes.polygon': '다각형', 'shapes.line': '선', 'shapes.icon': '아이콘', 'shapes.importImageSvg': '이미지 또는 SVG 가져오기\u2026', @@ -238,6 +239,14 @@ const ko: TranslationKeys = { 'export.exportFormat': '{{format}} 내보내기', 'export.exportLayer': '레이어 내보내기', + // ── Polygon ── + 'polygon.sides': '변 수', + + // ── Ellipse ── + 'ellipse.start': '시작', + 'ellipse.sweep': '스윕', + 'ellipse.innerRadius': '내경', + // ── Corner Radius ── 'cornerRadius.title': '모서리 반경', diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index a043903c0..a068ea102 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -30,6 +30,7 @@ const pt: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Retângulo', 'shapes.ellipse': 'Elipse', + 'shapes.polygon': 'Polígono', 'shapes.line': 'Linha', 'shapes.icon': 'Ícone', 'shapes.importImageSvg': 'Importar imagem ou SVG\u2026', @@ -240,6 +241,14 @@ const pt: TranslationKeys = { 'export.exportFormat': 'Exportar {{format}}', 'export.exportLayer': 'Exportar camada', + // ── Polygon ── + 'polygon.sides': 'Lados', + + // ── Ellipse ── + 'ellipse.start': 'Início', + 'ellipse.sweep': 'Varredura', + 'ellipse.innerRadius': 'Interior', + // ── Corner Radius ── 'cornerRadius.title': 'Raio dos cantos', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index ea8abfc43..089a2cca0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -30,6 +30,7 @@ const ru: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Прямоугольник', 'shapes.ellipse': 'Эллипс', + 'shapes.polygon': 'Полигон', 'shapes.line': 'Линия', 'shapes.icon': 'Иконка', 'shapes.importImageSvg': 'Импорт изображения или SVG\u2026', @@ -240,6 +241,14 @@ const ru: TranslationKeys = { 'export.exportFormat': 'Экспорт {{format}}', 'export.exportLayer': 'Экспортировать слой', + // ── Polygon ── + 'polygon.sides': 'Стороны', + + // ── Ellipse ── + 'ellipse.start': 'Начало', + 'ellipse.sweep': 'Размах', + 'ellipse.innerRadius': 'Внутр.', + // ── Corner Radius ── 'cornerRadius.title': 'Радиус углов', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index d175ee77a..61879ce7c 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -30,6 +30,7 @@ const th: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'สี่เหลี่ยมผืนผ้า', 'shapes.ellipse': 'วงรี', + 'shapes.polygon': 'รูปหลายเหลี่ยม', 'shapes.line': 'เส้น', 'shapes.icon': 'ไอคอน', 'shapes.importImageSvg': 'นำเข้ารูปภาพหรือ SVG\u2026', @@ -238,6 +239,14 @@ const th: TranslationKeys = { 'export.exportFormat': 'ส่งออก {{format}}', 'export.exportLayer': 'ส่งออกเลเยอร์', + // ── Polygon ── + 'polygon.sides': 'ด้าน', + + // ── Ellipse ── + 'ellipse.start': 'เริ่ม', + 'ellipse.sweep': 'กวาด', + 'ellipse.innerRadius': 'ภายใน', + // ── Corner Radius ── 'cornerRadius.title': 'รัศมีมุม', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 5b6655f64..14b59aa26 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -30,6 +30,7 @@ const tr: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Dikdörtgen', 'shapes.ellipse': 'Elips', + 'shapes.polygon': 'Çokgen', 'shapes.line': 'Çizgi', 'shapes.icon': 'Simge', 'shapes.importImageSvg': 'Görsel veya SVG İçe Aktar\u2026', @@ -238,6 +239,14 @@ const tr: TranslationKeys = { 'export.exportFormat': '{{format}} Dışa Aktar', 'export.exportLayer': 'Katmanı dışa aktar', + // ── Polygon ── + 'polygon.sides': 'Kenar', + + // ── Ellipse ── + 'ellipse.start': 'Başlangıç', + 'ellipse.sweep': 'Süpürme', + 'ellipse.innerRadius': 'İç', + // ── Corner Radius ── 'cornerRadius.title': 'Köşe Yarıçapı', diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 18abedfaf..72acc4420 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -30,6 +30,7 @@ const vi: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': 'Hình chữ nhật', 'shapes.ellipse': 'Hình elip', + 'shapes.polygon': 'Đa giác', 'shapes.line': 'Đường thẳng', 'shapes.icon': 'Biểu tượng', 'shapes.importImageSvg': 'Nhập ảnh hoặc SVG\u2026', @@ -238,6 +239,14 @@ const vi: TranslationKeys = { 'export.exportFormat': 'Xuất {{format}}', 'export.exportLayer': 'Xuất lớp', + // ── Polygon ── + 'polygon.sides': 'Cạnh', + + // ── Ellipse ── + 'ellipse.start': 'Bắt đầu', + 'ellipse.sweep': 'Quét', + 'ellipse.innerRadius': 'Bán kính trong', + // ── Corner Radius ── 'cornerRadius.title': 'Bán kính góc', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index 62f01634d..44a011e38 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -30,6 +30,7 @@ const zhTW: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': '矩形', 'shapes.ellipse': '橢圓', + 'shapes.polygon': '多邊形', 'shapes.line': '線條', 'shapes.icon': '圖示', 'shapes.importImageSvg': '匯入圖片或 SVG\u2026', @@ -233,6 +234,14 @@ const zhTW: TranslationKeys = { 'export.exportFormat': '匯出 {{format}}', 'export.exportLayer': '匯出圖層', + // ── Polygon ── + 'polygon.sides': '邊數', + + // ── Ellipse ── + 'ellipse.start': '起始', + 'ellipse.sweep': '掃過', + 'ellipse.innerRadius': '內徑', + // ── Corner Radius ── 'cornerRadius.title': '圓角', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index e179b30ca..ae96090ad 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -30,6 +30,7 @@ const zh: TranslationKeys = { // ── Shapes ── 'shapes.rectangle': '矩形', 'shapes.ellipse': '椭圆', + 'shapes.polygon': '多边形', 'shapes.line': '线条', 'shapes.icon': '图标', 'shapes.importImageSvg': '导入图片或 SVG\u2026', @@ -233,6 +234,14 @@ const zh: TranslationKeys = { 'export.exportFormat': '导出 {{format}}', 'export.exportLayer': '导出图层', + // ── Polygon ── + 'polygon.sides': '边数', + + // ── Ellipse ── + 'ellipse.start': '起始', + 'ellipse.sweep': '扫过', + 'ellipse.innerRadius': '内径', + // ── Corner Radius ── 'cornerRadius.title': '圆角', diff --git a/src/types/pen.ts b/src/types/pen.ts index a5c7a7029..227838dc7 100644 --- a/src/types/pen.ts +++ b/src/types/pen.ts @@ -108,6 +108,7 @@ export interface EllipseNode extends PenNodeBase { type: 'ellipse' width?: SizingBehavior height?: SizingBehavior + cornerRadius?: number innerRadius?: number startAngle?: number sweepAngle?: number @@ -129,6 +130,7 @@ export interface PolygonNode extends PenNodeBase { polygonCount: number width?: SizingBehavior height?: SizingBehavior + cornerRadius?: number fill?: PenFill[] stroke?: PenStroke effects?: PenEffect[] diff --git a/src/utils/boolean-ops.ts b/src/utils/boolean-ops.ts index be936fe45..9014a7185 100644 --- a/src/utils/boolean-ops.ts +++ b/src/utils/boolean-ops.ts @@ -78,11 +78,24 @@ function ellipseToPath(rx: number, ry: number): string { } function polygonToPath(count: number, w: number, h: number): string { - const parts: string[] = [] + const raw: [number, number][] = [] for (let i = 0; i < count; i++) { const angle = (i * 2 * Math.PI) / count - Math.PI / 2 - const px = (w / 2) * Math.cos(angle) + w / 2 - const py = (h / 2) * Math.sin(angle) + h / 2 + raw.push([Math.cos(angle), Math.sin(angle)]) + } + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity + for (const [rx, ry] of raw) { + if (rx < minX) minX = rx + if (rx > maxX) maxX = rx + if (ry < minY) minY = ry + if (ry > maxY) maxY = ry + } + const rw = maxX - minX + const rh = maxY - minY + const parts: string[] = [] + for (let i = 0; i < count; i++) { + const px = ((raw[i][0] - minX) / rw) * w + const py = ((raw[i][1] - minY) / rh) * h parts.push(i === 0 ? `M ${px} ${py}` : `L ${px} ${py}`) } parts.push('Z')