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')