V0.4.2 (#46)
* feat(docker): add Docker support with multi-stage build and CI workflow - Introduced a `.dockerignore` file to exclude unnecessary files from the Docker context. - Added a `Dockerfile` for multi-stage builds, optimizing the application for production with a slim runtime. - Created a GitHub Actions workflow (`docker.yml`) to automate the building and pushing of Docker images on version tag pushes. - Enhanced the `connect-agent.ts` and `install-agent.ts` files to improve OpenCode binary resolution and installation commands. - Updated the canvas components to support new polygon shape and related functionalities, including UI adjustments for shape tools and appearance settings. This update significantly enhances the deployment process and expands the application's capabilities with Docker integration. * feat(docker): enhance Dockerfile with multi-stage builds and CLI variants - Updated the Dockerfile to include multiple image variants for different CLI tools, including Claude, Codex, OpenCode, and Copilot, alongside the base web app. - Improved the build process by separating the build stage and production stage, optimizing the final image size. - Added environment variables and commands for each variant to ensure proper execution in production. - Enhanced the README files in multiple languages to document the new Docker deployment options and usage instructions. This update significantly expands the Docker deployment capabilities, allowing users to choose the appropriate image variant based on their needs.
This commit is contained in:
parent
a72121cf5a
commit
71c09b3342
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
77
.github/workflows/docker.yml
vendored
Normal 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
69
Dockerfile
Normal 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"]
|
||||
49
README.de.md
49
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**
|
||||
|
|
|
|||
49
README.es.md
49
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**
|
||||
|
|
|
|||
49
README.fr.md
49
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**
|
||||
|
|
|
|||
49
README.hi.md
49
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 तक**
|
||||
|
|
|
|||
49
README.id.md
49
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**
|
||||
|
|
|
|||
49
README.ja.md
49
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 へ**
|
||||
|
|
|
|||
49
README.ko.md
49
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로**
|
||||
|
|
|
|||
49
README.md
49
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**
|
||||
|
|
|
|||
49
README.pt.md
49
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**
|
||||
|
|
|
|||
49
README.ru.md
49
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**
|
||||
|
|
|
|||
49
README.th.md
49
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**
|
||||
|
|
|
|||
49
README.tr.md
49
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**
|
||||
|
|
|
|||
49
README.vi.md
49
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**
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
49
README.zh.md
49
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**
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const TOOL_KEYS: Record<string, ToolType> = {
|
|||
f: 'frame',
|
||||
r: 'rectangle',
|
||||
o: 'ellipse',
|
||||
y: 'polygon',
|
||||
l: 'line',
|
||||
t: 'text',
|
||||
p: 'path',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': 'कोने की गोलाई',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': '角丸',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': '모서리 반경',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': 'Радиус углов',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': 'รัศมีมุม',
|
||||
|
||||
|
|
|
|||
|
|
@ -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ı',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': '圓角',
|
||||
|
||||
|
|
|
|||
|
|
@ -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': '圆角',
|
||||
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in a new issue