* 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.
This commit is contained in:
Kayshen Xu 2026-03-17 21:07:50 +08:00 committed by GitHub
parent a72121cf5a
commit 71c09b3342
47 changed files with 1482 additions and 44 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.github
.vscode
.output
node_modules
dist
dist-electron
electron-dist
build
screenshot
*.md
!package.json
.openpencil-tmp

77
.github/workflows/docker.yml vendored Normal file
View file

@ -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 }}

69
Dockerfile Normal file
View file

@ -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"]

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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 तक**

View file

@ -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**

View file

@ -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 へ**

View file

@ -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로**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -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**

View file

@ -197,13 +197,71 @@ async function connectCodexCli(): Promise<ConnectResult> {
}
}
/** Resolve the opencode binary path, checking PATH then common install locations. */
async function resolveOpencodeBinary(): Promise<string | undefined> {
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<ConnectResult> {
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' }
}

View file

@ -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<InstallResult
}
async function tryOpenCodeInstall(binary: string): Promise<InstallResult> {
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',

View file

@ -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,

View file

@ -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<PenNode>)
} 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<PenNode>)
} 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<PenNode>)
}
}
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

View file

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

View file

@ -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.
*/

View file

@ -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)
}
}

View file

@ -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<string, ReactNode> = {
rectangle: <Square size={20} strokeWidth={1.5} />,
ellipse: <Circle size={20} strokeWidth={1.5} />,
polygon: <Triangle size={20} strokeWidth={1.5} />,
line: <Minus size={20} strokeWidth={1.5} />,
path: <PenTool size={20} strokeWidth={1.5} />,
}
@ -90,6 +92,7 @@ export default function ShapeToolDropdown({
const items: DropdownItem[] = [
{ type: 'tool', tool: 'rectangle', icon: <Square size={18} strokeWidth={1.5} />, label: t('shapes.rectangle') },
{ type: 'tool', tool: 'ellipse', icon: <Circle size={18} strokeWidth={1.5} />, label: t('shapes.ellipse') },
{ type: 'tool', tool: 'polygon', icon: <Triangle size={18} strokeWidth={1.5} />, label: t('shapes.polygon') },
{ type: 'tool', tool: 'line', icon: <Minus size={18} strokeWidth={1.5} />, label: t('shapes.line') },
{ type: 'action', key: 'icon', icon: <Sparkles size={18} strokeWidth={1.5} />, label: t('shapes.icon'), onAction: onIconPickerOpen },
{ type: 'action', key: 'image', icon: <ImagePlus size={18} strokeWidth={1.5} />, label: t('shapes.importImageSvg'), onAction: onImageImport },

View file

@ -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 (
<div className="space-y-1.5">
<SectionHeader title={t('appearance.layer')} />
<div className="flex items-center gap-1">
<div className="flex-1">
{isBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{rawOpacity}
</div>
) : (
<div className="grid grid-cols-2 gap-1">
<div className="flex items-center gap-1">
<div className="flex-1">
{isBound ? (
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
{rawOpacity}
</div>
) : (
<NumberInput
label={t('appearance.opacity')}
value={Math.round(opacity)}
onChange={(v) => onUpdate({ opacity: v / 100 })}
min={0}
max={100}
suffix="%"
/>
)}
</div>
<VariablePicker
type="number"
currentValue={isBound ? String(rawOpacity) : undefined}
onBind={(ref) => onUpdate({ opacity: ref as unknown as number })}
onUnbind={(val) => onUpdate({ opacity: Number(val) })}
/>
</div>
{isPolygon && (
<NumberInput
label={t('polygon.sides')}
value={(node as PolygonNode).polygonCount ?? 3}
onChange={(v) => onUpdate({ polygonCount: v } as Partial<PenNode>)}
min={3}
max={100}
/>
)}
</div>
{isEllipse && (
<>
<div className="grid grid-cols-3 gap-1 mt-1">
<NumberInput
label={t('appearance.opacity')}
value={Math.round(opacity)}
onChange={(v) => onUpdate({ opacity: v / 100 })}
label={t('ellipse.start')}
value={Math.round((node as EllipseNode).startAngle ?? 0)}
onChange={(v) => onUpdate({ startAngle: v } as Partial<PenNode>)}
min={0}
max={100}
max={360}
suffix="°"
/>
<NumberInput
label={t('ellipse.sweep')}
value={Math.round((node as EllipseNode).sweepAngle ?? 360)}
onChange={(v) => onUpdate({ sweepAngle: v } as Partial<PenNode>)}
min={0}
max={360}
suffix="°"
/>
<NumberInput
label={t('ellipse.innerRadius')}
value={Math.round(((node as EllipseNode).innerRadius ?? 0) * 100)}
onChange={(v) => onUpdate({ innerRadius: v / 100 } as Partial<PenNode>)}
min={0}
max={99}
suffix="%"
/>
)}
</div>
<VariablePicker
type="number"
currentValue={isBound ? String(rawOpacity) : undefined}
onBind={(ref) => onUpdate({ opacity: ref as unknown as number })}
onUnbind={(val) => onUpdate({ opacity: Number(val) })}
/>
</div>
</div>
</>
)}
</div>
)
}

View file

@ -10,7 +10,7 @@ import {
Lock,
Unlock,
FolderOpen,
Hexagon,
Triangle,
Spline,
Link,
ImageIcon,
@ -28,7 +28,7 @@ const TYPE_ICONS: Record<PenNodeType, typeof Square> = {
line: Minus,
frame: Frame,
group: FolderOpen,
polygon: Hexagon,
polygon: Triangle,
path: Spline,
image: ImageIcon,
icon_font: Smile,

View file

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

View file

@ -23,6 +23,7 @@ const TOOL_KEYS: Record<string, ToolType> = {
f: 'frame',
r: 'rectangle',
o: 'ellipse',
y: 'polygon',
l: 'line',
t: 'text',
p: 'path',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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': 'कोने की गोलाई',

View file

@ -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',

View file

@ -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': '角丸',

View file

@ -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': '모서리 반경',

View file

@ -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',

View file

@ -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': 'Радиус углов',

View file

@ -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': 'รัศมีมุม',

View file

@ -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ı',

View file

@ -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',

View file

@ -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': '圓角',

View file

@ -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': '圆角',

View file

@ -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[]

View file

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