diff --git a/CLAUDE.md b/CLAUDE.md index af8cfb5a..25855470 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**. Also ships as an **Electron** desktop app for macOS, Windows, and Linux. -**Key technologies:** React 19, Fabric.js v7 (canvas engine), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode). +**Key technologies:** React 19, Fabric.js v7 (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode). ### Data Flow @@ -133,7 +133,7 @@ PenDocument (source of truth) - `agent-settings.ts` — AI provider config types (`AIProviderType`: anthropic/openai/opencode/copilot, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`) - `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations, updater: `UpdaterState`/`UpdaterStatus`, `getState`/`checkForUpdates`/`quitAndInstall`/`onStateChange`) - `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk -- **`src/components/editor/`** — Editor UI (8 files): editor-layout, toolbar (with variables panel toggle), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification) +- **`src/components/editor/`** — Editor UI (9 files): editor-layout, toolbar (with variables panel toggle), boolean-toolbar (contextual floating toolbar for union/subtract/intersect, shown when 2+ compatible shapes selected), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification) - **`src/components/panels/`** — Panels (26 files): - `layer-panel.tsx` / `layer-item.tsx` / `layer-context-menu.tsx` — Tree view with drag-and-drop reordering and drop-into-children (above/below/inside), visibility/lock toggles, context menu, rename - `property-panel.tsx` — Unified property panel @@ -194,8 +194,8 @@ PenDocument (source of truth) - `figma-image-resolver.ts` — Resolves image blob references - **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator - **`src/hooks/`** — Hooks (2 files): - - `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order - - `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores + - `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order, boolean operations (Cmd+Alt+U/S/I) + - `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores; also handles `onOpenFile` for `.op` file association - **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging) - **`src/uikit/`** — UI kit system (3 files + `kits/` subdir): - `built-in-registry.ts` — Default built-in UIKit with standard UI components @@ -207,7 +207,7 @@ PenDocument (source of truth) - `document-manager.ts` — MCP utility for reading, writing, and caching PenDocuments from disk - `tools/` — Individual MCP tool implementations: `batch-design.ts`, `batch-get.ts`, `find-empty-space.ts`, `open-document.ts`, `snapshot-layout.ts`, `variables.ts`, `pages.ts` (page CRUD: add/remove/rename/reorder/duplicate) - `utils/` — Shared utilities: `id.ts`, `node-operations.ts` (page-aware `getDocChildren`/`setDocChildren`) -- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight +- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight, boolean operations (union/subtract/intersect via Paper.js) - **`server/api/ai/`** — Nitro server API (7 files): `chat.ts` (streaming SSE with thinking state, multimodal image attachments per provider), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode/Copilot connection), `models.ts` (model definitions), `validate.ts` (vision-based post-generation validation), `mcp-install.ts` (MCP server install/uninstall into CLI tool configs), `icon.ts` (icon name → SVG path resolution via local Iconify sets). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers - **`server/utils/`** — Server utilities (5 files): - `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`) @@ -250,13 +250,14 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr ### Electron Desktop App -- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, native application menu, auto-updater, macOS traffic-light padding (auto-hidden in fullscreen) -- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state) -- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb) +- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, native application menu, auto-updater, macOS traffic-light padding (auto-hidden in fullscreen), `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux) +- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association) +- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association (`fileAssociations`) - **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron - Build flow: `BUILD_TARGET=electron bun run build` → `bun run electron:compile` → `npx electron-builder` - In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor` - Auto-updater checks GitHub Releases on startup and every hour; `update-ready-banner.tsx` shows download progress and "Restart & Install" prompt +- **File association:** `.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`. On macOS the `open-file` app event handles double-click/drag; on Windows/Linux `requestSingleInstanceLock` + `second-instance` event forwards CLI args to the existing window. Pending file paths are queued until the renderer is ready, then sent via `file:open` IPC channel. The renderer (`use-electron-menu.ts`) listens via `onOpenFile`, reads the file through `file:read` IPC, and calls `loadDocument`. ### CI / CD diff --git a/README.de.md b/README.de.md index cba8f55b..2a10cf0d 100644 --- a/README.de.md +++ b/README.de.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Schnellstart · KI · Funktionen · - Discord · + Discord · Mitwirken

@@ -89,6 +89,7 @@ OpenPencil wurde von Grund auf mit KI im Kern aufgebaut — nicht als Plugin, so **Canvas und Zeichnen** - Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten - Rechteck, Ellipse, Linie, Polygon, Stift (Bezier), Frame, Text +- Boolesche Operationen — Vereinigung, Subtraktion, Schnittmenge mit kontextbezogener Werkzeugleiste - Icon-Auswahl (Iconify) und Bildimport (PNG/JPEG/SVG/WebP/GIF) - Auto-Layout — vertikal/horizontal mit Gap, Padding, Justify, Align - Mehrseitige Dokumente mit Tab-Navigation @@ -156,6 +157,8 @@ electron/ | `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel | | `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat | | Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen | +| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion | +| `Cmd+Alt+I` | Boolesche Schnittmenge | | | | ## Skripte @@ -186,7 +189,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail - [x] MCP-Server-Integration - [x] Mehrseitige Unterstützung - [x] Figma-`.fig`-Import -- [ ] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge) +- [x] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge) - [ ] Kollaboratives Bearbeiten - [ ] Plugin-System @@ -198,7 +201,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail ## Community - + Discord Unserem Discord beitreten diff --git a/README.es.md b/README.es.md index c2a41585..ada96b1d 100644 --- a/README.es.md +++ b/README.es.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Inicio Rápido · IA · Características · - Discord · + Discord · Contribuir

@@ -89,6 +89,7 @@ OpenPencil está construido desde cero con IA en su núcleo — no como un plugi **Lienzo y Dibujo** - Lienzo infinito con panorámica, zoom, guías de alineación inteligentes y ajuste - Rectángulo, Elipse, Línea, Polígono, Pluma (Bezier), Frame, Texto +- Operaciones booleanas — unión, resta, intersección con barra de herramientas contextual - Selector de iconos (Iconify) e importación de imágenes (PNG/JPEG/SVG/WebP/GIF) - Diseño automático — vertical/horizontal con gap, padding, justify, align - Documentos multipágina con navegación por pestañas @@ -156,6 +157,8 @@ electron/ | `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables | | `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA | | Flechas | Mover 1px | | `Cmd+,` | Configuración de agente | +| `Cmd+Alt+U` | Unión booleana | | `Cmd+Alt+S` | Resta booleana | +| `Cmd+Alt+I` | Intersección booleana | | | | ## Scripts @@ -186,7 +189,7 @@ bun run electron:build # Empaquetado de Electron - [x] Integración con servidor MCP - [x] Soporte multipágina - [x] Importación de Figma `.fig` -- [ ] Operaciones booleanas (unión, sustracción, intersección) +- [x] Operaciones booleanas (unión, sustracción, intersección) - [ ] Edición colaborativa - [ ] Sistema de plugins @@ -198,7 +201,7 @@ bun run electron:build # Empaquetado de Electron ## Comunidad - + Discord Únete a nuestro Discord diff --git a/README.fr.md b/README.fr.md index 2ea2641f..6ded892d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Démarrage rapide · IA · Fonctionnalités · - Discord · + Discord · Contribuer

@@ -89,6 +89,7 @@ OpenPencil est conçu autour de l'IA dès le départ — non pas comme un plugin **Canevas et dessin** - Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme - Rectangle, Ellipse, Ligne, Polygone, Plume (Bézier), Frame, Texte +- Opérations booléennes — union, soustraction, intersection avec barre d'outils contextuelle - Sélecteur d'icônes (Iconify) et import d'images (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — vertical/horizontal avec gap, padding, justify, align - Documents multi-pages avec navigation par onglets @@ -156,6 +157,8 @@ electron/ | `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables | | `[ / ]` | Réordonner | | `Cmd+J` | Chat IA | | Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent | +| `Cmd+Alt+U` | Union booléenne | | `Cmd+Alt+S` | Soustraction booléenne | +| `Cmd+Alt+I` | Intersection booléenne | | | | ## Scripts @@ -186,7 +189,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour - [x] Intégration du serveur MCP - [x] Support multi-pages - [x] Import Figma `.fig` -- [ ] Opérations booléennes (union, soustraction, intersection) +- [x] Opérations booléennes (union, soustraction, intersection) - [ ] Édition collaborative - [ ] Système de plugins @@ -198,7 +201,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour ## Communauté - + Discord Rejoindre notre Discord diff --git a/README.hi.md b/README.hi.md index 4858ef3d..a74a8a30 100644 --- a/README.hi.md +++ b/README.hi.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

त्वरित शुरुआत · AI · विशेषताएँ · - Discord · + Discord · योगदान

@@ -89,6 +89,7 @@ OpenPencil को AI के इर्द-गिर्द शुरू से ब **कैनवास और ड्रॉइंग** - पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text +- बूलियन ऑपरेशन — संयोजन, घटाना, प्रतिच्छेदन संदर्भ टूलबार के साथ - आइकन पिकर (Iconify) और इमेज इम्पोर्ट (PNG/JPEG/SVG/WebP/GIF) - ऑटो-लेआउट — gap, padding, justify, align के साथ वर्टिकल/हॉरिज़ॉन्टल - टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़ @@ -156,6 +157,8 @@ electron/ | `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल | | `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट | | Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स | +| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना | +| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | | ## स्क्रिप्ट @@ -186,7 +189,7 @@ bun run electron:build # Electron पैकेज - [x] MCP सर्वर इंटीग्रेशन - [x] मल्टी-पेज सपोर्ट - [x] Figma `.fig` इम्पोर्ट -- [ ] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट) +- [x] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट) - [ ] सहयोगी संपादन - [ ] प्लगइन सिस्टम @@ -198,7 +201,7 @@ bun run electron:build # Electron पैकेज ## समुदाय - + Discord हमारे Discord में शामिल हों diff --git a/README.id.md b/README.id.md index e659985f..039d9c8f 100644 --- a/README.id.md +++ b/README.id.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Mulai Cepat · AI · Fitur · - Discord · + Discord · Berkontribusi

@@ -89,6 +89,7 @@ OpenPencil dibangun dengan AI sebagai inti — bukan sebagai plugin, melainkan s **Kanvas & Menggambar** - Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping - Persegi panjang, Elips, Garis, Poligon, Pen (Bezier), Frame, Teks +- Operasi Boolean — gabungan, kurangi, irisan dengan toolbar kontekstual - Pemilih ikon (Iconify) dan impor gambar (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — vertikal/horizontal dengan gap, padding, justify, align - Dokumen multi-halaman dengan navigasi tab @@ -156,6 +157,8 @@ electron/ | `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel | | `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI | | Panah | Geser 1px | | `Cmd+,` | Pengaturan agen | +| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean | +| `Cmd+Alt+I` | Intersect Boolean | | | | ## Skrip @@ -186,7 +189,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt - [x] Integrasi server MCP - [x] Dukungan multi-halaman - [x] Impor Figma `.fig` -- [ ] Operasi boolean (gabung, kurangi, potong) +- [x] Operasi boolean (gabung, kurangi, potong) - [ ] Pengeditan kolaboratif - [ ] Sistem plugin @@ -198,7 +201,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt ## Komunitas - + Discord Bergabung dengan Discord kami diff --git a/README.ja.md b/README.ja.md index d4cc056b..3b00cafe 100644 --- a/README.ja.md +++ b/README.ja.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

クイックスタート · AI · 機能 · - Discord · + Discord · コントリビュート

@@ -89,6 +89,7 @@ OpenPencil はプラグインとしてではなく、コアワークフローと **キャンバスと描画** - パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス - 矩形、楕円、直線、多角形、ペン(ベジェ)、Frame、テキスト +- ブーリアン演算 — 合体、型抜き、交差(コンテキストツールバー付き) - アイコンピッカー(Iconify)と画像インポート(PNG/JPEG/SVG/WebP/GIF) - オートレイアウト — 垂直/水平方向、ギャップ・パディング・justify・align 対応 - タブナビゲーション付きマルチページドキュメント @@ -156,6 +157,8 @@ electron/ | `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル | | `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット | | 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 | +| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き | +| `Cmd+Alt+I` | ブーリアン交差 | | | | ## スクリプト @@ -186,7 +189,7 @@ bun run electron:build # Electron パッケージング - [x] MCP サーバー統合 - [x] マルチページサポート - [x] Figma `.fig` インポート -- [ ] ブール演算(結合、減算、交差) +- [x] ブール演算(結合、減算、交差) - [ ] 共同編集 - [ ] プラグインシステム @@ -198,7 +201,7 @@ bun run electron:build # Electron パッケージング ## コミュニティ - + Discord Discord に参加する diff --git a/README.ko.md b/README.ko.md index 019e276f..7636cfb0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

빠른 시작 · AI · 기능 · - Discord · + Discord · 기여하기

@@ -89,6 +89,7 @@ OpenPencil은 AI를 플러그인이 아닌 핵심 워크플로로서 처음부 **캔버스 & 드로잉** - 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스 - 사각형, 타원, 직선, 다각형, 펜(베지어), Frame, 텍스트 +- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 도구 모음) - 아이콘 피커(Iconify)와 이미지 가져오기(PNG/JPEG/SVG/WebP/GIF) - 오토 레이아웃 — 수직/수평 방향, 갭·패딩·justify·align 지원 - 탭 내비게이션이 있는 멀티 페이지 문서 @@ -156,6 +157,8 @@ electron/ | `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 | | `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 | | 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 | +| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 | +| `Cmd+Alt+I` | 불리언 교차 | | | | ## 스크립트 @@ -186,7 +189,7 @@ bun run electron:build # Electron 패키징 - [x] MCP 서버 통합 - [x] 멀티 페이지 지원 - [x] Figma `.fig` 가져오기 -- [ ] 불리언 연산(결합, 빼기, 교차) +- [x] 불리언 연산(결합, 빼기, 교차) - [ ] 공동 편집 - [ ] 플러그인 시스템 @@ -198,7 +201,7 @@ bun run electron:build # Electron 패키징 ## 커뮤니티 - + Discord Discord에 참여하기 diff --git a/README.md b/README.md index f76bbc51..3e8b8947 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Quick Start · AI · Features · - Discord · + Discord · Contributing

@@ -91,6 +91,7 @@ OpenPencil is built around AI from the ground up — not as a plugin, but as a c **Canvas & Drawing** - Infinite canvas with pan, zoom, smart alignment guides, and snapping - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text +- Boolean operations — union, subtract, intersect with contextual toolbar - Icon picker (Iconify) and image import (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — vertical/horizontal with gap, padding, justify, align - Multi-page documents with tab navigation @@ -106,6 +107,7 @@ OpenPencil is built around AI from the ground up — not as a plugin, but as a c **Desktop App** - Native macOS, Windows, and Linux via Electron +- `.op` file association — double-click to open, single-instance lock - Auto-update from GitHub Releases - Native application menu and file dialogs @@ -158,6 +160,8 @@ electron/ | `Del` | Delete | | `Cmd+Shift+V` | Variables panel | | `[ / ]` | Reorder | | `Cmd+J` | AI chat | | Arrows | Nudge 1px | | `Cmd+,` | Agent settings | +| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract | +| `Cmd+Alt+I` | Boolean intersect | | | | ## Scripts @@ -188,7 +192,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details - [x] MCP server integration - [x] Multi-page support - [x] Figma `.fig` import -- [ ] Boolean operations (union, subtract, intersect) +- [x] Boolean operations (union, subtract, intersect) - [ ] Collaborative editing - [ ] Plugin system @@ -200,7 +204,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details ## Community - + Discord Join our Discord diff --git a/README.pt.md b/README.pt.md index 3c69a168..56cbe873 100644 --- a/README.pt.md +++ b/README.pt.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Início Rápido · IA · Funcionalidades · - Discord · + Discord · Contribuindo

@@ -89,6 +89,7 @@ O OpenPencil é construído com IA desde o início — não como um plugin, mas **Canvas e Desenho** - Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping - Retângulo, Elipse, Linha, Polígono, Caneta (Bezier), Frame, Texto +- Operações booleanas — união, subtração, interseção com barra de ferramentas contextual - Seletor de ícones (Iconify) e importação de imagens (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — vertical/horizontal com gap, padding, justify, align - Documentos com múltiplas páginas e navegação por abas @@ -156,6 +157,8 @@ electron/ | `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis | | `[ / ]` | Reordenar | | `Cmd+J` | Chat IA | | Setas | Mover 1px | | `Cmd+,` | Configurações do agente | +| `Cmd+Alt+U` | União booleana | | `Cmd+Alt+S` | Subtração booleana | +| `Cmd+Alt+I` | Interseção booleana | | | | ## Scripts @@ -186,7 +189,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh - [x] Integração com servidor MCP - [x] Suporte a múltiplas páginas - [x] Importação do Figma `.fig` -- [ ] Operações booleanas (união, subtração, interseção) +- [x] Operações booleanas (união, subtração, interseção) - [ ] Edição colaborativa - [ ] Sistema de plugins @@ -198,7 +201,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh ## Comunidade - + Discord Entre no nosso Discord diff --git a/README.ru.md b/README.ru.md index 1749b09d..f8d73889 100644 --- a/README.ru.md +++ b/README.ru.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Быстрый старт · AI · Возможности · - Discord · + Discord · Участие в разработке

@@ -89,6 +89,7 @@ OpenPencil построен вокруг AI с самого начала — н **Холст и рисование** - Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой - Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст +- Булевы операции — объединение, вычитание, пересечение с контекстной панелью инструментов - Выбор иконок (Iconify) и импорт изображений (PNG/JPEG/SVG/WebP/GIF) - Авто-раскладка — вертикальная/горизонтальная с gap, padding, justify, align - Многостраничные документы с навигацией по вкладкам @@ -156,6 +157,8 @@ electron/ | `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных | | `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат | | Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента | +| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание | +| `Cmd+Alt+I` | Булево пересечение | | | | ## Скрипты @@ -186,7 +189,7 @@ bun run electron:build # Упаковка Electron - [x] Интеграция с MCP-сервером - [x] Поддержка нескольких страниц - [x] Импорт Figma `.fig` -- [ ] Булевы операции (объединение, вычитание, пересечение) +- [x] Булевы операции (объединение, вычитание, пересечение) - [ ] Совместное редактирование - [ ] Система плагинов @@ -198,7 +201,7 @@ bun run electron:build # Упаковка Electron ## Сообщество - + Discord Присоединяйтесь к нашему Discord diff --git a/README.th.md b/README.th.md index 00dd72f8..e8a683dd 100644 --- a/README.th.md +++ b/README.th.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

เริ่มต้นอย่างรวดเร็ว · AI · ฟีเจอร์ · - Discord · + Discord · มีส่วนร่วม

@@ -89,6 +89,7 @@ OpenPencil ถูกสร้างขึ้นโดยมี AI เป็น **Canvas และการวาด** - Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping - Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text +- การดำเนินการบูลีน — รวม ลบ ตัดกัน พร้อมแถบเครื่องมือตามบริบท - ตัวเลือก Icon (Iconify) และนำเข้ารูปภาพ (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — แนวตั้ง/แนวนอนพร้อม gap, padding, justify, align - เอกสารหลายหน้าพร้อมการนำทางด้วย tab @@ -156,6 +157,8 @@ electron/ | `Del` | ลบ | | `Cmd+Shift+V` | Variables panel | | `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat | | ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings | +| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน | +| `Cmd+Alt+I` | ตัดกันบูลีน | | | | ## Scripts @@ -186,7 +189,7 @@ bun run electron:build # Electron package - [x] การเชื่อมต่อ MCP server - [x] รองรับหลายหน้า - [x] นำเข้า Figma `.fig` -- [ ] Boolean operations (union, subtract, intersect) +- [x] Boolean operations (union, subtract, intersect) - [ ] การแก้ไขร่วมกัน - [ ] ระบบปลั๊กอิน @@ -198,7 +201,7 @@ bun run electron:build # Electron package ## ชุมชน - + Discord เข้าร่วม Discord ของเรา diff --git a/README.tr.md b/README.tr.md index c6e9a019..cb39f9c7 100644 --- a/README.tr.md +++ b/README.tr.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Hızlı Başlangıç · AI · Özellikler · - Discord · + Discord · Katkıda Bulunma

@@ -89,6 +89,7 @@ OpenPencil, AI'yi bir eklenti olarak değil, temel iş akışı olarak sıfırda **Kanvas ve Çizim** - Kaydırma, yakınlaştırma, akıllı hizalama kılavuzları ve yakalamayı destekleyen sonsuz kanvas - Dikdörtgen, Elips, Çizgi, Çokgen, Kalem (Bezier), Frame, Metin +- Boolean işlemler — birleştir, çıkar, kesiştir bağlamsal araç çubuğuyla - Simge seçici (Iconify) ve görsel içe aktarma (PNG/JPEG/SVG/WebP/GIF) - Otomatik düzen — boşluk, dolgu, justify, align ile dikey/yatay - Sekme navigasyonlu çok sayfalı belgeler @@ -156,6 +157,8 @@ electron/ | `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli | | `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet | | Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları | +| `Cmd+Alt+U` | Boolean birleştir | | `Cmd+Alt+S` | Boolean çıkar | +| `Cmd+Alt+I` | Boolean kesiştir | | | | ## Betikler @@ -186,7 +189,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md] - [x] MCP sunucu entegrasyonu - [x] Çok sayfa desteği - [x] Figma `.fig` içe aktarma -- [ ] Boolean işlemler (birleştirme, çıkarma, kesişim) +- [x] Boolean işlemler (birleştirme, çıkarma, kesişim) - [ ] Ortak düzenleme - [ ] Eklenti sistemi @@ -198,7 +201,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md] ## Topluluk - + Discord Discord'umuza katılın diff --git a/README.vi.md b/README.vi.md index 31ed0879..1979baaf 100644 --- a/README.vi.md +++ b/README.vi.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

Bắt đầu nhanh · AI · Tính năng · - Discord · + Discord · Đóng góp

@@ -89,6 +89,7 @@ OpenPencil được xây dựng xung quanh AI từ nền tảng — không phả **Canvas và Vẽ** - Canvas vô hạn với pan, zoom, hướng dẫn căn chỉnh thông minh và snapping - Hình chữ nhật, Hình ellipse, Đường thẳng, Đa giác, Bút (Bezier), Frame, Văn bản +- Phép toán Boolean — hợp nhất, trừ, giao nhau với thanh công cụ ngữ cảnh - Trình chọn icon (Iconify) và nhập hình ảnh (PNG/JPEG/SVG/WebP/GIF) - Auto-layout — dọc/ngang với gap, padding, justify, align - Tài liệu nhiều trang với điều hướng bằng tab @@ -156,6 +157,8 @@ electron/ | `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến | | `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat | | Mũi tên | Dịch chuyển 1px | | `Cmd+,` | Cài đặt tác nhân | +| `Cmd+Alt+U` | Hợp nhất Boolean | | `Cmd+Alt+S` | Trừ Boolean | +| `Cmd+Alt+I` | Giao nhau Boolean | | | | ## Scripts @@ -186,7 +189,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v - [x] Tích hợp máy chủ MCP - [x] Hỗ trợ nhiều trang - [x] Nhập Figma `.fig` -- [ ] Phép toán Boolean (hợp nhất, trừ, giao) +- [x] Phép toán Boolean (hợp nhất, trừ, giao) - [ ] Chỉnh sửa cộng tác - [ ] Hệ thống plugin @@ -198,7 +201,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v ## Cộng đồng - + Discord Tham gia Discord của chúng tôi diff --git a/README.zh-TW.md b/README.zh-TW.md index dce13649..e67671e1 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

快速開始 · AI · 功能特色 · - Discord · + Discord · 參與貢獻

@@ -78,6 +78,7 @@ OpenPencil 從底層就圍繞 AI 構建——不是作為外掛程式,而是 **畫布與繪圖** - 無限畫布,支援平移、縮放、智慧對齊參考線和吸附 - 矩形、橢圓、直線、多邊形、鋼筆(貝茲曲線)、Frame、文字 +- 布林運算 — 聯合、減去、交集,搭配上下文工具列 - 圖示選擇器(Iconify)和圖片匯入(PNG/JPEG/SVG/WebP/GIF) - 自動版面配置 — 垂直/水平方向,支援間距、內邊距、主軸對齊、交叉軸對齊 - 多頁面文件,支援分頁導覽 @@ -145,6 +146,8 @@ electron/ | `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 | | `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 | | 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 | +| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 | +| `Cmd+Alt+I` | 布林交集 | | | | ## 指令碼命令 @@ -175,7 +178,7 @@ bun run electron:build # Electron 封裝 - [x] MCP 伺服器整合 - [x] 多頁面支援 - [x] Figma `.fig` 匯入 -- [ ] 布林運算(聯集、減去、交集) +- [x] 布林運算(聯集、減去、交集) - [ ] 協同編輯 - [ ] 外掛程式系統 @@ -187,7 +190,7 @@ bun run electron:build # Electron 封裝 ## 社群 - + Discord 加入我們的 Discord diff --git a/README.zh.md b/README.zh.md index 430b8ee5..5180d19d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -17,14 +17,14 @@ Stars License CI - Discord + Discord

快速开始 · AI · 功能特性 · - Discord · + Discord · 参与贡献

@@ -78,6 +78,7 @@ OpenPencil 从底层就围绕 AI 构建——不是作为插件,而是作为 **画布与绘图** - 无限画布,支持平移、缩放、智能对齐参考线和吸附 - 矩形、椭圆、直线、多边形、钢笔(贝塞尔)、Frame、文本 +- 布尔运算 — 联合、减去、交集,配合上下文工具栏 - 图标选择器(Iconify)和图片导入(PNG/JPEG/SVG/WebP/GIF) - 自动布局 — 垂直/水平方向,支持间距、内边距、主轴对齐、交叉轴对齐 - 多页面文档,支持标签页导航 @@ -145,6 +146,8 @@ electron/ | `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 | | `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 | | 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 | +| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 | +| `Cmd+Alt+I` | 布尔交集 | | | | ## 脚本命令 @@ -175,7 +178,7 @@ bun run electron:build # Electron 打包 - [x] MCP 服务器集成 - [x] 多页面支持 - [x] Figma `.fig` 导入 -- [ ] 布尔运算(合并、减去、相交) +- [x] 布尔运算(合并、减去、相交) - [ ] 协同编辑 - [ ] 插件系统 @@ -187,12 +190,16 @@ bun run electron:build # Electron 打包 ## 社区 - + Discord 加入我们的 Discord — 提问、分享设计、提出功能建议。 +**飞书交流群** + +飞书交流群 + ## 许可证 [MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W diff --git a/bun.lock b/bun.lock index d55675c1..1aa5ebd6 100644 --- a/bun.lock +++ b/bun.lock @@ -31,12 +31,14 @@ "electron-updater": "^6.6.2", "fabric": "^7.1.0", "fzstd": "^0.1.1", + "html2canvas": "^1.4.1", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "kiwi-schema": "^0.5.0", "lucide-react": "^0.545.0", "nanoid": "^5.1.6", "nitro": "npm:nitro-nightly@latest", + "paper": "^0.12.18", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.5.4", @@ -688,6 +690,8 @@ "balanced-match": ["balanced-match@4.0.3", "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.3.tgz", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], + "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], + "base64-js": ["base64-js@1.5.1", "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], @@ -810,6 +814,8 @@ "crossws": ["crossws@0.4.4", "https://registry.npmmirror.com/crossws/-/crossws-0.4.4.tgz", { "peerDependencies": { "srvx": ">=0.7.1" }, "optionalPeers": ["srvx"] }, "sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg=="], + "css-line-break": ["css-line-break@2.1.0", "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "css-select": ["css-select@5.2.2", "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-tree": ["css-tree@3.1.0", "https://registry.npmmirror.com/css-tree/-/css-tree-3.1.0.tgz", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], @@ -1060,6 +1066,8 @@ "html-parse-stringify": ["html-parse-stringify@3.0.1", "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "html2canvas": ["html2canvas@1.4.1", "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "htmlparser2": ["htmlparser2@10.1.0", "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], @@ -1302,6 +1310,8 @@ "package-json-from-dist": ["package-json-from-dist@1.0.1", "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "paper": ["paper@0.12.18", "https://registry.npmmirror.com/paper/-/paper-0.12.18.tgz", {}, "sha512-ZSLIEejQTJZuYHhSSqAf4jXOnii0kPhCJGAnYAANtdS72aNwXJ9cP95tZHgq1tnNpvEwgQhggy+4OarviqTCGw=="], + "parse5": ["parse5@8.0.0", "https://registry.npmmirror.com/parse5/-/parse5-8.0.0.tgz", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], @@ -1540,6 +1550,8 @@ "temp-file": ["temp-file@3.4.0", "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], + "text-segmentation": ["text-segmentation@1.0.3", "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], + "tiny-async-pool": ["tiny-async-pool@1.3.0", "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="], "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], @@ -1628,6 +1640,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "utrie": ["utrie@1.0.2", "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + "uzip": ["uzip@0.20201231.0", "https://registry.npmmirror.com/uzip/-/uzip-0.20201231.0.tgz", {}, "sha512-OZeJfZP+R0z9D6TmBgLq2LHzSSptGMGDGigGiEe0pr8UBe/7fdflgHlHBNDASTXB5jnFuxHpNaJywSg8YFeGng=="], "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], diff --git a/electron-builder.yml b/electron-builder.yml index 3c219a68..04203472 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -50,6 +50,14 @@ linux: - AppImage - deb +fileAssociations: + - ext: op + name: OpenPencil Document + description: OpenPencil Design File + mimeType: application/x-openpencil + role: Editor + icon: build/icon + asar: true publish: diff --git a/electron/main.ts b/electron/main.ts index 9ec5e478..f22e0f0b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -20,9 +20,14 @@ let nitroProcess: ChildProcess | null = null let serverPort = 0 let autoUpdateEnabled = true let updateCheckTimer: ReturnType | null = null +let pendingFilePath: string | null = null const isDev = !app.isPackaged -const SETTINGS_PATH = join(homedir(), '.openpencil', 'settings.json') +// Settings stored in platform-standard app data dir (Electron-managed): +// macOS: ~/Library/Application Support/OpenPencil/ +// Windows: %APPDATA%\OpenPencil\ +// Linux: ~/.config/OpenPencil/ +const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json') type UpdaterStatus = | 'disabled' @@ -218,7 +223,7 @@ async function readAppSettings(): Promise { async function writeAppSettings(patch: Partial): Promise { const current = await readAppSettings() const merged = { ...current, ...patch } - await mkdir(PORT_FILE_DIR, { recursive: true }) + await mkdir(app.getPath('userData'), { recursive: true }) await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8') } @@ -237,8 +242,8 @@ async function writePortFile(port: number): Promise { JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }), 'utf-8', ) - } catch { - // Non-critical — MCP sync will fall back to file I/O + } catch (err) { + console.error('[port-file] Failed to write port file:', err) } } @@ -379,8 +384,9 @@ function createWindow(): void { ...(isWinOrLinux ? { titleBarOverlay: { - color: 'rgba(0,0,0,0)', - symbolColor: '#a1a1aa', + // Windows supports transparent overlay; Linux needs solid color + color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#111', + symbolColor: '#d4d4d8', height: 36, }, } @@ -389,6 +395,9 @@ function createWindow(): void { preload: join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, + // Persist localStorage/cookies in a fixed partition so data survives + // across random Nitro server port changes (origin-independent storage). + partition: 'persist:openpencil', }, } @@ -523,17 +532,45 @@ function setupIPC(): void { }, ) - // Theme sync for Windows/Linux title bar overlay - ipcMain.handle('theme:set', (_event, theme: 'dark' | 'light') => { - if (!mainWindow || mainWindow.isDestroyed()) return - const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' - if (!isWinOrLinux) return - mainWindow.setTitleBarOverlay({ - color: 'rgba(0,0,0,0)', - symbolColor: theme === 'dark' ? '#a1a1aa' : '#71717a', - }) + ipcMain.handle('file:getPending', () => { + if (pendingFilePath) { + const filePath = pendingFilePath + pendingFilePath = null + return filePath + } + return null }) + ipcMain.handle('file:read', async (_event, filePath: string) => { + const resolved = resolve(filePath) + const ext = extname(resolved).toLowerCase() + if (ext !== '.op' && ext !== '.pen') return null + try { + const content = await readFile(resolved, 'utf-8') + return { filePath: resolved, content } + } catch { + return null + } + }) + + // Theme sync for Windows/Linux title bar overlay + ipcMain.handle( + 'theme:set', + (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => { + if (!mainWindow || mainWindow.isDestroyed()) return + const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' + if (!isWinOrLinux) return + const isLinux = process.platform === 'linux' + const fallbackBg = theme === 'dark' ? '#111' : '#fff' + const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46' + mainWindow.setTitleBarOverlay({ + // Windows supports transparent overlay; Linux uses actual CSS card color + color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)', + symbolColor: colors?.fg || fallbackFg, + }) + }, + ) + ipcMain.handle('updater:getState', () => updaterState) ipcMain.handle('updater:checkForUpdates', async () => { @@ -699,6 +736,58 @@ function buildAppMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } +// --------------------------------------------------------------------------- +// File association: open .op files +// --------------------------------------------------------------------------- + +/** Extract .op file path from command-line arguments. */ +function getFilePathFromArgs(args: string[]): string | null { + for (const arg of args) { + if (arg.endsWith('.op') || arg.endsWith('.pen')) { + return arg + } + } + return null +} + +/** Send a file path to the renderer for loading. */ +function sendOpenFile(filePath: string): void { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('file:open', filePath) + } else { + pendingFilePath = filePath + } +} + +// macOS: open-file fires when user double-clicks a .op file +app.on('open-file', (event, filePath) => { + event.preventDefault() + if (app.isReady()) { + sendOpenFile(filePath) + } else { + pendingFilePath = filePath + } +}) + +// Single instance lock (Windows/Linux: second instance passes file path as arg) +const gotTheLock = app.requestSingleInstanceLock() + +if (!gotTheLock) { + app.quit() +} else { + app.on('second-instance', (_event, argv) => { + const filePath = getFilePathFromArgs(argv) + if (filePath) { + sendOpenFile(filePath) + } + // Focus existing window + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) +} + // --------------------------------------------------------------------------- // App lifecycle // --------------------------------------------------------------------------- @@ -725,6 +814,13 @@ app.on('ready', async () => { createWindow() + // Check for file to open: pending open-file event or CLI args (Windows/Linux). + // The file path is stored in pendingFilePath and pulled by the renderer + // via file:getPending IPC when the React app mounts (useElectronMenu hook). + if (!pendingFilePath) { + pendingFilePath = getFilePathFromArgs(process.argv) + } + if (!isDev) { const settings = await readAppSettings() autoUpdateEnabled = settings.autoUpdate !== false diff --git a/electron/preload.ts b/electron/preload.ts index 561be632..988b7505 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -28,7 +28,10 @@ export interface ElectronAPI { ) => Promise saveToPath: (filePath: string, content: string) => Promise onMenuAction: (callback: (action: string) => void) => () => void - setTheme: (theme: 'dark' | 'light') => void + onOpenFile: (callback: (filePath: string) => void) => () => void + readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null> + getPendingFile: () => Promise + setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void updater: { getState: () => Promise checkForUpdates: () => Promise @@ -50,7 +53,8 @@ const api: ElectronAPI = { saveToPath: (filePath: string, content: string) => ipcRenderer.invoke('dialog:saveToPath', { filePath, content }), - setTheme: (theme: 'dark' | 'light') => ipcRenderer.invoke('theme:set', theme), + setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => + ipcRenderer.invoke('theme:set', theme, colors), onMenuAction: (callback: (action: string) => void) => { const listener = (_event: IpcRendererEvent, action: string) => { @@ -62,6 +66,20 @@ const api: ElectronAPI = { } }, + onOpenFile: (callback: (filePath: string) => void) => { + const listener = (_event: IpcRendererEvent, filePath: string) => { + callback(filePath) + } + ipcRenderer.on('file:open', listener) + return () => { + ipcRenderer.removeListener('file:open', listener) + } + }, + + readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath), + + getPendingFile: () => ipcRenderer.invoke('file:getPending'), + updater: { getState: () => ipcRenderer.invoke('updater:getState'), checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'), diff --git a/package.json b/package.json index e71eaeb1..281dfec7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openpencil", - "version": "0.2.1", + "version": "0.3.0", "description": "Open-source vector design tool with Design-as-Code philosophy", "author": { "name": "ZSeven-W", @@ -54,12 +54,14 @@ "electron-updater": "^6.6.2", "fabric": "^7.1.0", "fzstd": "^0.1.1", + "html2canvas": "^1.4.1", "i18next": "^25.8.14", "i18next-browser-languagedetector": "^8.2.1", "kiwi-schema": "^0.5.0", "lucide-react": "^0.545.0", "nanoid": "^5.1.6", "nitro": "npm:nitro-nightly@latest", + "paper": "^0.12.18", "react": "^19.2.0", "react-dom": "^19.2.0", "react-i18next": "^16.5.4", diff --git a/screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg b/screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg new file mode 100644 index 00000000..472ce590 Binary files /dev/null and b/screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg differ diff --git a/server/api/ai/connect-agent.ts b/server/api/ai/connect-agent.ts index ef882c94..e3d7982b 100644 --- a/server/api/ai/connect-agent.ts +++ b/server/api/ai/connect-agent.ts @@ -117,10 +117,11 @@ async function connectCodexCli(): Promise { const { join } = await import('node:path') // Check if codex binary exists - const which = execSync('which codex 2>/dev/null || echo ""', { + const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""' + const which = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000, - }).trim() + }).trim().split(/\r?\n/)[0]?.trim() ?? '' if (!which) { return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' } diff --git a/server/api/ai/validate.ts b/server/api/ai/validate.ts index 25604222..f393cc0d 100644 --- a/server/api/ai/validate.ts +++ b/server/api/ai/validate.ts @@ -63,8 +63,20 @@ function toImageBase64(data: string): string { async function withTempImageFile( imageBase64: string, run: (tempPath: string) => Promise, + insideProject = false, ): Promise { - const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')) + let tempDir: string + if (insideProject) { + // Save inside the project directory so Claude Code Agent SDK (plan mode) + // can read the file — it restricts reads to the project directory. + const { mkdirSync, chmodSync } = await import('node:fs') + const baseDir = join(process.cwd(), '.openpencil-tmp') + mkdirSync(baseDir, { recursive: true }) + chmodSync(baseDir, 0o700) + tempDir = await mkdtemp(join(baseDir, 'validate-')) + } else { + tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')) + } const tempPath = join(tempDir, 'screenshot.png') try { await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64')) @@ -75,8 +87,10 @@ async function withTempImageFile( } /** - * Agent SDK fallback: save screenshot to a temp PNG file, then ask Claude - * Code to read it (Claude Code's Read tool supports images natively). + * Agent SDK: save screenshot to a temp PNG file inside the project directory, + * then ask Claude Code to read it (Claude Code's Read tool supports images + * natively). Must use insideProject=true because plan mode restricts reads + * to the project directory. */ async function validateViaAgentSDK( body: ValidateBody, @@ -87,23 +101,23 @@ async function validateViaAgentSDK( const env = buildClaudeAgentEnv() const debugFile = getClaudeAgentDebugFilePath() - const claudePath = resolveClaudeCli() - // Prompt Claude Code to read the temp image and analyze it - const prompt = `Read the image file at "${tempPath}" and analyze it as a UI design screenshot. + const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design. -${body.message} +After viewing the image, analyze it according to these instructions: ${body.system} -Output ONLY the JSON object, no markdown fences, no explanation.` +${body.message} + +CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.` const q = query({ prompt, options: { ...(model ? { model } : {}), - maxTurns: 2, + maxTurns: 3, tools: [], plugins: [], permissionMode: 'plan', @@ -131,7 +145,7 @@ Output ONLY the JSON object, no markdown fences, no explanation.` } return { text: '', skipped: true } - }) + }, true) } async function validateViaCodex( diff --git a/server/plugins/port-file.ts b/server/plugins/port-file.ts new file mode 100644 index 00000000..3e14625b --- /dev/null +++ b/server/plugins/port-file.ts @@ -0,0 +1,48 @@ +/** + * Nitro plugin — writes ~/.openpencil/.port on server startup so the MCP + * server can discover the running instance (dev server or Electron). + * + * In Electron production mode the main process also writes this file, + * but this plugin ensures the dev server (`bun --bun run dev`) is + * discoverable too. + */ + +import { writeFile, mkdir, unlink } from 'node:fs/promises' +import { join } from 'node:path' +import { homedir } from 'node:os' + +const PORT_FILE_DIR = join(homedir(), '.openpencil') +const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port') + +async function writePortFile(port: number): Promise { + try { + await mkdir(PORT_FILE_DIR, { recursive: true }) + await writeFile( + PORT_FILE_PATH, + JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }), + 'utf-8', + ) + } catch { + // Non-critical — MCP sync will fall back to file I/O + } +} + +async function cleanupPortFile(): Promise { + try { + await unlink(PORT_FILE_PATH) + } catch { + // Ignore if already removed + } +} + +export default () => { + const port = parseInt(process.env.PORT || '3000', 10) + writePortFile(port) + + const cleanup = () => { + cleanupPortFile() + } + process.on('beforeExit', cleanup) + process.on('SIGINT', cleanup) + process.on('SIGTERM', cleanup) +} diff --git a/server/utils/codex-client.ts b/server/utils/codex-client.ts index 0dedeaec..766bdd82 100644 --- a/server/utils/codex-client.ts +++ b/server/utils/codex-client.ts @@ -29,7 +29,12 @@ const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000 * Only passes through safe system vars and provider-specific prefixes. * Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc. */ -const CODEX_ENV_ALLOWLIST = new Set(['PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR']) +const CODEX_ENV_ALLOWLIST = new Set([ + 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR', + // Windows-essential vars + 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', + 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', +]) export function filterCodexEnv( env: Record, @@ -146,6 +151,8 @@ async function executeCodexCommand( const child = spawn('codex', args, { env: filterCodexEnv(process.env as Record), stdio: ['ignore', 'pipe', 'pipe'], + // On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve them + ...(process.platform === 'win32' && { shell: true }), }) let stdoutBuffer = '' diff --git a/server/utils/copilot-client.ts b/server/utils/copilot-client.ts index 91a4db4e..6e0c4978 100644 --- a/server/utils/copilot-client.ts +++ b/server/utils/copilot-client.ts @@ -1,9 +1,14 @@ import { execSync } from 'node:child_process' +const isWindows = process.platform === 'win32' + /** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */ export function resolveCopilotCli(): string | undefined { try { - const path = execSync('which copilot 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim() + const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null' + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() + // `where` on Windows may return multiple lines + const path = result.split(/\r?\n/)[0]?.trim() return path || undefined } catch { return undefined diff --git a/server/utils/mcp-server-manager.ts b/server/utils/mcp-server-manager.ts index c5e7a7f5..17151a5e 100644 --- a/server/utils/mcp-server-manager.ts +++ b/server/utils/mcp-server-manager.ts @@ -1,10 +1,28 @@ -import { fork, type ChildProcess } from 'node:child_process' +import { fork, execSync, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' import { networkInterfaces } from 'node:os' -import { resolve } from 'node:path' +import { join, resolve } from 'node:path' let mcpProcess: ChildProcess | null = null let mcpPort: number | null = null +/** Resolve the MCP server script path across dev, web build, and Electron production. */ +function resolveMcpServerScript(): string { + // Electron production: extraResources + const electronResources = process.env.ELECTRON_RESOURCES_PATH + if (electronResources) { + const p = join(electronResources, 'mcp-server.cjs') + if (existsSync(p)) return p + } + // dev + web build + const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs') + if (existsSync(fromCwd)) return fromCwd + // Fallback: relative to this file (Nitro bundled output) + const fromFile = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs') + if (existsSync(fromFile)) return fromFile + return fromCwd +} + /** Get the first non-internal IPv4 address (LAN IP). */ export function getLocalIp(): string | null { const nets = networkInterfaces() @@ -32,7 +50,7 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb return { running: true, port: mcpPort!, localIp: getLocalIp() } } - const serverScript = resolve(process.cwd(), 'dist/mcp-server.cjs') + const serverScript = resolveMcpServerScript() try { mcpProcess = fork(serverScript, ['--http', '--port', String(port)], { @@ -60,7 +78,17 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb export function stopMcpHttpServer(): { running: false } { if (mcpProcess) { - mcpProcess.kill('SIGTERM') + if (process.platform === 'win32') { + // SIGTERM is unreliable on Windows; use taskkill for proper cleanup + try { + const pid = mcpProcess.pid + if (pid) { + execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' }) + } + } catch { /* ignore */ } + } else { + mcpProcess.kill('SIGTERM') + } mcpProcess = null mcpPort = null } diff --git a/server/utils/resolve-claude-cli.ts b/server/utils/resolve-claude-cli.ts index 7c1439fc..0ff27bc1 100644 --- a/server/utils/resolve-claude-cli.ts +++ b/server/utils/resolve-claude-cli.ts @@ -1,8 +1,10 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' -import { homedir } from 'node:os' +import { homedir, platform } from 'node:os' import { join } from 'node:path' +const isWindows = platform() === 'win32' + /** * Resolve the absolute path to the standalone `claude` binary. * @@ -13,23 +15,31 @@ import { join } from 'node:path' * binaries and spawns them directly (no `node` wrapper needed). */ export function resolveClaudeCli(): string | undefined { - // 1. Try `which claude` (works when PATH is correctly set) + // 1. Try PATH lookup try { - const p = execSync('which claude 2>/dev/null', { + const cmd = isWindows ? 'where claude' : 'which claude 2>/dev/null' + const p = execSync(cmd, { encoding: 'utf-8', timeout: 3000, - }).trim() + }).trim().split(/\r?\n/)[0] // `where` on Windows may return multiple lines if (p && existsSync(p)) return p } catch { /* not in PATH */ } // 2. Common install locations - const candidates = [ - join(homedir(), '.local', 'bin', 'claude'), - '/usr/local/bin/claude', - '/opt/homebrew/bin/claude', - ] + const candidates = isWindows + ? [ + join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'), + join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'), + join(homedir(), '.claude', 'local', 'claude.exe'), + join(homedir(), 'AppData', 'Local', 'Programs', 'claude-code', 'claude.exe'), + ] + : [ + join(homedir(), '.local', 'bin', 'claude'), + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + ] for (const c of candidates) { - if (existsSync(c)) return c + if (c && existsSync(c)) return c } return undefined diff --git a/src/canvas/canvas-layout-engine.ts b/src/canvas/canvas-layout-engine.ts index c9b94334..c8c28cb3 100644 --- a/src/canvas/canvas-layout-engine.ts +++ b/src/canvas/canvas-layout-engine.ts @@ -3,9 +3,11 @@ import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store' import { parseSizing, estimateTextWidth, + estimateTextWidthPrecise, estimateTextHeight, estimateLineWidth, getTextOpticalCenterYOffset, + defaultLineHeight, } from './canvas-text-measure' // --------------------------------------------------------------------------- @@ -175,7 +177,11 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number { typeof node.content === 'string' ? node.content : node.content.map((s) => s.text).join('') - return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing)), 20) + // Use precise estimation (no safety factor) for fit-content / natural-width + // text. IText auto-computes its own width and ignores ours, so the safety + // margin only inflates the layout allocation, making the text appear + // left-shifted within its overwide box. + return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing)), 20) } return 0 } @@ -299,19 +305,16 @@ export function computeLayoutPositions( const childCross = isVertical ? size.w : size.h let crossPos = 0 - // For text nodes, use the actual Fabric-rendered height for cross-axis - // centering instead of the declared height. Fabric.js text height = - // fontSize * lineHeight, which is typically smaller than the AI-declared - // height, causing text to appear shifted upward when centered. + // For text nodes in horizontal layout with center alignment, use the actual + // Fabric-rendered height (fontSize * lineHeight) instead of the declared + // height, since Fabric text is shorter than AI-declared height. let effectiveChildCross = childCross if (align === 'center' && child.type === 'text') { const fontSize = child.fontSize ?? 16 - const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? 1.2 + const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? defaultLineHeight(fontSize) const visualH = fontSize * lineHeight if (!isVertical && visualH < childCross) { effectiveChildCross = visualH - } else if (isVertical && visualH < childCross) { - // vertical layout: cross axis is width, not applicable } } @@ -340,8 +343,8 @@ export function computeLayoutPositions( crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize)) } - const computedX = isVertical ? pad.left + crossPos : pad.left + mainPos - const computedY = isVertical ? pad.top + mainPos : pad.top + crossPos + const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos) + const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos) mainPos += (isVertical ? size.h : size.w) + effectiveGap @@ -355,6 +358,35 @@ export function computeLayoutPositions( width: size.w, height: size.h, } + + // For text nodes centered in a vertical layout, expand to full available + // width and set textAlign:'center'. This avoids width estimation inaccuracy: + // IText ignores our width and computes its own, so textAlign has no effect. + // By using full width (which triggers Textbox in the factory) + center align, + // the text is precisely centered regardless of glyph estimation error. + if (isVertical && align === 'center' && child.type === 'text') { + const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left' + if (!hasExplicitAlign) { + out.width = availW + out.x = Math.round(pad.left) + out.textAlign = 'center' + } + } + + // For fit-content text nodes in horizontal layouts, set textAlign:'center' + // to compensate for width estimation inaccuracy. The estimated box is + // typically slightly wider than the actual rendered text, so left-aligned + // text appears visually shifted left. Centering the text within its box + // distributes the error evenly on both sides. + if (!isVertical && child.type === 'text') { + const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left' + const widthMode = 'width' in child ? parseSizing(child.width) : 0 + const isFitContent = widthMode === 'fit' || widthMode === 0 + if (!hasExplicitAlign && isFitContent) { + out.textAlign = 'center' + } + } + return out as unknown as PenNode }) } diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index c84a7a2f..c1c961bd 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -14,6 +14,7 @@ import { DEFAULT_STROKE_WIDTH, SELECTION_BLUE, } from './canvas-constants' +import { defaultLineHeight } from './canvas-text-measure' import { applyRotationControls } from './canvas-controls' function angleToCoords( @@ -150,7 +151,13 @@ function shouldSplitByGrapheme(text: string): boolean { function isFixedWidthText(node: PenNode): boolean { if (node.type !== 'text') return false - return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height' + if (node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height') return true + // When textAlign is not 'left', the layout engine injected centering. + // IText ignores width and computes its own, making textAlign ineffective + // for single-line text. Use Textbox so the width is respected and + // textAlign:'center' actually centers the text within the box. + if (node.textAlign && node.textAlign !== 'left') return true + return false } function sizeToNumber( @@ -425,7 +432,7 @@ export function createFabricObject( textAlign: node.textAlign ?? 'left', underline: node.underline ?? false, linethrough: node.strikethrough ?? false, - lineHeight: node.lineHeight ?? 1.2, + lineHeight: node.lineHeight ?? defaultLineHeight(node.fontSize ?? 16), charSpacing: node.letterSpacing ? (node.letterSpacing / (node.fontSize || 16)) * 1000 : 0, diff --git a/src/canvas/canvas-text-measure.ts b/src/canvas/canvas-text-measure.ts index 01655bcd..7957f882 100644 --- a/src/canvas/canvas-text-measure.ts +++ b/src/canvas/canvas-text-measure.ts @@ -14,6 +14,20 @@ export function parseSizing(value: unknown): number | 'fit' | 'fill' { return isNaN(n) ? 0 : n } +// --------------------------------------------------------------------------- +// Default line height — single source of truth for all modules +// --------------------------------------------------------------------------- + +/** + * Canonical default lineHeight when a text node has no explicit value. + * Display/heading text (>=28px) gets tighter spacing; body text gets looser. + * All modules (factory, layout engine, text estimation, AI generation) + * MUST use this function instead of hardcoded fallbacks. + */ +export function defaultLineHeight(fontSize: number): number { + return fontSize >= 28 ? 1.2 : 1.5 +} + // --------------------------------------------------------------------------- // CJK detection // --------------------------------------------------------------------------- @@ -85,6 +99,19 @@ export function estimateTextWidth(text: string, fontSize: number, letterSpacing return maxLine } +/** + * Estimate text width WITHOUT safety factor. + * Used for layout centering where the safety margin causes text to appear + * off-center (the overestimated width shifts the text box left when centered). + * For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor. + */ +export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0): number { + const lines = text.split(/\r?\n/) + return lines.reduce((max, line) => { + return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing)) + }, 0) +} + // --------------------------------------------------------------------------- // Text content helpers // --------------------------------------------------------------------------- @@ -108,7 +135,8 @@ export function countExplicitTextLines(text: string): number { /** * Optical vertical correction for centered single-line text. * Font line boxes are mathematically centered but glyph ink tends to look - * slightly top-heavy, especially for CJK, so we nudge down by 1-3px. + * slightly top-heavy, especially for CJK, so we nudge down proportionally. + * The offset scales with fontSize (no fixed cap) so large text stays centered. */ export function getTextOpticalCenterYOffset(node: PenNode): number { if (node.type !== 'text') return 0 @@ -117,13 +145,16 @@ export function getTextOpticalCenterYOffset(node: PenNode): number { if (countExplicitTextLines(text) > 1) return 0 const fontSize = node.fontSize ?? 16 - const lineHeight = node.lineHeight ?? 1.2 + const lineHeight = node.lineHeight ?? defaultLineHeight(fontSize) const hasCjk = hasCjkText(text) - const ratio = hasCjk ? 0.16 : 0.1 - const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.7 + // Base ratio: CJK glyphs sit higher in the em box than Latin + const ratio = hasCjk ? 0.12 : 0.07 + // When lineHeight is compact (≤1.35), the visual offset is more pronounced + const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.65 const offset = fontSize * ratio * compactLineBoost - return Math.max(1, Math.min(5, Math.round(offset))) + // Proportional cap: never exceed 8% of fontSize, minimum 1px + return Math.max(1, Math.min(Math.round(fontSize * 0.08), Math.round(offset))) } // --------------------------------------------------------------------------- @@ -135,7 +166,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb // Access text-specific properties via Record to avoid union type issues const n = node as unknown as Record const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16) - const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : 1.2) + const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize)) const singleLineH = fontSize * lineHeight // Get text content diff --git a/src/canvas/use-agent-indicators.ts b/src/canvas/use-agent-indicators.ts index 33afa8cc..2f185c3f 100644 --- a/src/canvas/use-agent-indicators.ts +++ b/src/canvas/use-agent-indicators.ts @@ -3,13 +3,13 @@ * * Visual effects: * 1. Soft colored border on nodes being generated (breathing opacity) - * 2. Agent badge next to the frame name with a spinning dot animation - * 3. Preview fill tint on nodes that haven't materialized yet + * 2. Breathing glow border around agent-owned frames (same color as badge) + * 3. Agent badge right-aligned above the frame with a spinning dot animation + * 4. Preview fill tint on nodes that haven't materialized yet */ import { useEffect } from 'react' import { useCanvasStore } from '@/stores/canvas-store' -import { useDocumentStore } from '@/stores/document-store' import { getActiveAgentIndicators, getActiveAgentFrames, @@ -23,7 +23,6 @@ const BADGE_FONT_SIZE = 11 const BADGE_PAD_X = 6 const BADGE_PAD_Y = 3 const BADGE_RADIUS = 4 -const BADGE_GAP = 6 const DOT_RADIUS = 3 export function useAgentIndicators() { @@ -106,25 +105,39 @@ export function useAgentIndicators() { } } - // ---- Draw agent badges next to frame names ---- + // ---- Draw agent frame glow borders + badges ---- for (const frame of agentFrames.values()) { const obj = objMap.get(frame.frameId) if (!obj) continue const corners = obj.getCoords() - const x = Math.min(...corners.map((p) => p.x)) - const y = Math.min(...corners.map((p) => p.y)) + const xs = corners.map((p) => p.x) + const ys = corners.map((p) => p.y) + const fx = Math.min(...xs) + const fy = Math.min(...ys) + const fw = Math.max(...xs) - fx + const fh = Math.max(...ys) - fy + if (fw < 1 || fh < 1) continue - // Measure the existing frame label width to position badge after it + // --- Breathing glow border around the frame --- + const glowWidth = 1.5 / zoom + ctx.save() + ctx.strokeStyle = frame.color + ctx.lineWidth = glowWidth + ctx.globalAlpha = breath * 0.8 + // Outer glow (wider, more transparent) + ctx.shadowColor = frame.color + ctx.shadowBlur = 8 / zoom + ctx.strokeRect(fx, fy, fw, fh) + // Inner crisp border (no shadow) + ctx.shadowColor = 'transparent' + ctx.shadowBlur = 0 + ctx.globalAlpha = breath + ctx.strokeRect(fx, fy, fw, fh) + ctx.restore() + + // --- Badge: right-aligned above the frame --- const fontSize = BADGE_FONT_SIZE / zoom - const labelFontSize = 12 / zoom - ctx.font = `500 ${labelFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` - const docStore = useDocumentStore.getState() - const frameNode = docStore.getNodeById(frame.frameId) - const frameName = frameNode?.name ?? frameNode?.type ?? '' - const labelWidth = frameName ? ctx.measureText(frameName).width + BADGE_GAP / zoom : 0 - - // Badge position: right of frame label, above frame const padX = BADGE_PAD_X / zoom const padY = BADGE_PAD_Y / zoom const radius = BADGE_RADIUS / zoom @@ -133,12 +146,12 @@ export function useAgentIndicators() { ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` const textWidth = ctx.measureText(frame.name).width - // Space for spinning dot + text const dotSpace = dotR * 2 + 4 / zoom const badgeW = dotSpace + textWidth + padX * 2 const badgeH = fontSize + padY * 2 - const badgeX = x + labelWidth - const badgeY = y - labelOffsetY - badgeH + // Right-align badge to frame's right edge + const badgeX = fx + fw - badgeW + const badgeY = fy - labelOffsetY - badgeH // Badge background (pill shape) ctx.globalAlpha = 0.9 diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 081f1f5f..3cf444d3 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -467,11 +467,13 @@ export function useCanvasSync() { // class/shape data changes (e.g. IText↔Textbox). // Path `d` changes are handled in-place by syncFabricObject. let objectRecreated = false - // Text nodes may need recreation when textGrowth mode changes + // Text nodes may need recreation when textGrowth mode or textAlign changes // (IText ↔ Textbox are different Fabric classes). + // Must match isFixedWidthText() in canvas-object-factory.ts. if (existingObj && node.type === 'text') { const growth = node.textGrowth const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' + || (node.textAlign != null && node.textAlign !== 'left') const isTextbox = existingObj instanceof fabric.Textbox if (needsTextbox !== isTextbox) { canvas.remove(existingObj) diff --git a/src/components/editor/boolean-toolbar.tsx b/src/components/editor/boolean-toolbar.tsx new file mode 100644 index 00000000..06b209d1 --- /dev/null +++ b/src/components/editor/boolean-toolbar.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { + SquaresUnite, + SquaresSubtract, + SquaresIntersect, +} from 'lucide-react' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { useCanvasStore } from '@/stores/canvas-store' +import { useDocumentStore } from '@/stores/document-store' +import { useHistoryStore } from '@/stores/history-store' +import type { PenNode } from '@/types/pen' +import { + canBooleanOp, + executeBooleanOp, + type BooleanOpType, +} from '@/utils/boolean-ops' + +const OPS = [ + { type: 'union' as BooleanOpType, icon: SquaresUnite, labelKey: 'layerMenu.booleanUnion', shortcut: '\u2318\u2325U' }, + { type: 'subtract' as BooleanOpType, icon: SquaresSubtract, labelKey: 'layerMenu.booleanSubtract', shortcut: '\u2318\u2325S' }, + { type: 'intersect' as BooleanOpType, icon: SquaresIntersect, labelKey: 'layerMenu.booleanIntersect', shortcut: '\u2318\u2325I' }, +] as const + +export default function BooleanToolbar() { + const { t } = useTranslation() + const selectedIds = useCanvasStore((s) => s.selection.selectedIds) + + const nodes = selectedIds + .map((id) => useDocumentStore.getState().getNodeById(id)) + .filter((n): n is PenNode => n != null) + + const show = canBooleanOp(nodes) + + const handleOp = useCallback((opType: BooleanOpType) => { + const { selectedIds } = useCanvasStore.getState().selection + const currentNodes = selectedIds + .map((id) => useDocumentStore.getState().getNodeById(id)) + .filter((n): n is PenNode => n != null) + + if (!canBooleanOp(currentNodes)) return + const result = executeBooleanOp(currentNodes, opType) + if (!result) return + + useHistoryStore.getState().pushState(useDocumentStore.getState().document) + for (const id of selectedIds) { + useDocumentStore.getState().removeNode(id) + } + useDocumentStore.getState().addNode(null, result) + useCanvasStore.getState().setSelection([result.id], result.id) + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas) { + canvas.discardActiveObject() + canvas.requestRenderAll() + } + }, []) + + if (!show) return null + + return ( +
+ {OPS.map((op) => ( + + + + + + {t(op.labelKey)} + + {op.shortcut} + + + + ))} +
+ ) +} diff --git a/src/components/editor/editor-layout.tsx b/src/components/editor/editor-layout.tsx index 9a8ef200..c32752e9 100644 --- a/src/components/editor/editor-layout.tsx +++ b/src/components/editor/editor-layout.tsx @@ -2,11 +2,11 @@ import { lazy, Suspense, useState, useCallback, useEffect } from 'react' import { TooltipProvider } from '@/components/ui/tooltip' import TopBar from './top-bar' import Toolbar from './toolbar' +import BooleanToolbar from './boolean-toolbar' import StatusBar from './status-bar' import LayerPanel from '@/components/panels/layer-panel' -import PropertyPanel from '@/components/panels/property-panel' +import RightPanel from '@/components/panels/right-panel' import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel' -import CodePanel from '@/components/panels/code-panel' import VariablesPanel from '@/components/panels/variables-panel' import ComponentBrowserPanel from '@/components/panels/component-browser-panel' import ExportDialog from '@/components/shared/export-dialog' @@ -36,17 +36,12 @@ export default function EditorLayout() { useCanvasStore.getState().setFigmaImportDialogOpen(false) }, []) const browserOpen = useUIKitStore((s) => s.browserOpen) - const codePanelOpen = useCanvasStore((s) => s.codePanelOpen) const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen) const closeSaveDialog = useCallback(() => { useDocumentStore.getState().setSaveDialogOpen(false) }, []) const [exportOpen, setExportOpen] = useState(false) - const toggleCodePanel = useCallback(() => { - useCanvasStore.getState().toggleCodePanel() - }, []) - const closeExport = useCallback(() => { setExportOpen(false) }, []) @@ -62,10 +57,10 @@ export default function EditorLayout() { return } - // Cmd+Shift+C: toggle code panel + // Cmd+Shift+C: switch right panel to code tab if (isMod && e.shiftKey && e.key.toLowerCase() === 'c') { e.preventDefault() - toggleCodePanel() + useCanvasStore.getState().setRightPanelTab('code') return } @@ -106,7 +101,7 @@ export default function EditorLayout() { } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) - }, [toggleMinimize, toggleCodePanel]) + }, [toggleMinimize]) // Handle Electron native menu actions useElectronMenu() @@ -144,6 +139,7 @@ export default function EditorLayout() { + {/* Floating variables panel — anchored to the right of the toolbar */} {variablesPanelOpen && } @@ -164,9 +160,8 @@ export default function EditorLayout() { {/* Expanded AI panel (floating, draggable) */} - {hasSelection && } + {hasSelection && } - {codePanelOpen && useCanvasStore.getState().setCodePanelOpen(false)} />} diff --git a/src/components/editor/top-bar.tsx b/src/components/editor/top-bar.tsx index be8b952c..1593a4a2 100644 --- a/src/components/editor/top-bar.tsx +++ b/src/components/editor/top-bar.tsx @@ -40,6 +40,21 @@ import { zoomToFitContent } from '@/canvas/use-fabric-canvas' import { useAgentSettingsStore } from '@/stores/agent-settings-store' import type { AIProviderType } from '@/types/agent-settings' +/** Convert a computed CSS color value (oklch/rgb/etc.) to #rrggbb via an offscreen canvas. */ +function cssToHex(raw: string): string | null { + const v = raw.trim() + if (!v) return null + try { + const ctx = document.createElement('canvas').getContext('2d') + if (!ctx) return null + ctx.fillStyle = v + const hex = ctx.fillStyle // browser normalises to #rrggbb + return hex.startsWith('#') ? hex : null + } catch { + return null + } +} + const PROVIDER_ICONS: Record>> = { anthropic: ClaudeLogo, openai: OpenAILogo, @@ -123,6 +138,18 @@ export default function TopBar() { const [theme, setTheme] = useState<'dark' | 'light'>('dark') const [isFullscreen, setIsFullscreen] = useState(false) + // Read computed CSS --card and --card-foreground as hex for Electron overlay + const syncOverlayColors = useCallback((t: 'dark' | 'light') => { + if (!window.electronAPI?.setTheme) return + // Allow a frame for CSS to apply after class toggle + requestAnimationFrame(() => { + const s = getComputedStyle(document.documentElement) + const bg = cssToHex(s.getPropertyValue('--card')) + const fg = cssToHex(s.getPropertyValue('--card-foreground')) + window.electronAPI!.setTheme(t, bg && fg ? { bg, fg } : undefined) + }) + }, []) + // Restore saved theme after hydration useEffect(() => { try { @@ -130,14 +157,14 @@ export default function TopBar() { if (saved === 'light') { document.documentElement.classList.add('light') setTheme('light') - window.electronAPI?.setTheme?.('light') + syncOverlayColors('light') } else { - window.electronAPI?.setTheme?.('dark') + syncOverlayColors('dark') } } catch { // ignore } - }, []) + }, [syncOverlayColors]) // Listen to fullscreen changes useEffect(() => { @@ -154,13 +181,13 @@ export default function TopBar() { document.documentElement.classList.remove('light') } setTheme(next) - window.electronAPI?.setTheme?.(next) + syncOverlayColors(next) try { localStorage.setItem('openpencil-theme', next) } catch { // ignore } - }, [theme]) + }, [theme, syncOverlayColors]) const toggleFullscreen = useCallback(() => { if (document.fullscreenElement) { diff --git a/src/components/panels/ai-chat-checklist.tsx b/src/components/panels/ai-chat-checklist.tsx index 3e5ab43e..f087e94f 100644 --- a/src/components/panels/ai-chat-checklist.tsx +++ b/src/components/panels/ai-chat-checklist.tsx @@ -1,5 +1,5 @@ import { useState, useMemo } from 'react' -import { Pencil, ChevronDown, Check } from 'lucide-react' +import { Pencil, ChevronDown, Check, AlertTriangle } from 'lucide-react' import { cn } from '@/lib/utils' import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types' import { @@ -8,6 +8,13 @@ import { buildPipelineProgress, } from './chat-message' +/** Parse [done]/[pending]/[error] prefix from a detail line */ +function parseDetailStatus(line: string): { status: 'done' | 'pending' | 'error' | null; text: string } { + const match = line.match(/^\[(done|pending|error)\]\s*(.*)$/) + if (match) return { status: match[1] as 'done' | 'pending' | 'error', text: match[2] } + return { status: null, text: line } +} + /** Fixed collapsible checklist pinned between messages and input */ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) { const [collapsed, setCollapsed] = useState(false) @@ -27,7 +34,7 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag const planSteps = steps.filter((s) => s.title !== 'Thinking') if (planSteps.length === 0) return [] const jsonCount = countDesignJsonBlocks(content) - const isApplied = content.includes('\u2705') || content.includes('') + const isApplied = content.includes('\u2705') || content.includes('') || content.includes('[done] Applied') const hasError = /\*\*Error:\*\*/i.test(content) return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError) }, [lastAssistant, isStreaming]) @@ -64,27 +71,54 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag {!collapsed && (
{items.map((item, index) => ( -
- - {item.done ? ( - - ) : ( - - )} - - {item.label} +
+
+ + {item.done ? ( + + ) : ( + + )} + + {item.label} +
+ {item.details && item.details.length > 0 && ( +
+ {item.details.map((line, di) => { + const { status, text } = parseDetailStatus(line) + return ( + + {status === 'done' && ( + + + + )} + {status === 'pending' && ( + + + + )} + {status === 'error' && ( + + )} + {text} + + ) + })} +
+ )}
))}
diff --git a/src/components/panels/ai-chat-handlers.ts b/src/components/panels/ai-chat-handlers.ts index a240855b..b1e06022 100644 --- a/src/components/panels/ai-chat-handlers.ts +++ b/src/components/panels/ai-chat-handlers.ts @@ -15,20 +15,44 @@ import { trimChatHistory } from '@/services/ai/context-optimizer' import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types' import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config' -/** Detect if a message is a design generation request */ -export function isDesignRequest(text: string): boolean { - const lower = text.toLowerCase() - const designKeywords = [ - '生成', '设计', '创建', '画', '做一个', '来一个', '弄一个', - 'generate', 'create', 'design', 'make', 'build', 'draw', - 'add a', 'add an', 'place a', 'insert', - '界面', '页面', 'screen', 'page', 'layout', 'component', - '按钮', '卡片', '导航', '表单', '输入框', '列表', - 'button', 'card', 'nav', 'form', 'input', 'list', - 'header', 'footer', 'sidebar', 'modal', 'dialog', - 'login', 'signup', 'dashboard', 'profile', - ] - return designKeywords.some((kw) => lower.includes(kw)) +/** Intent classification prompt — lightweight LLM call to determine message routing */ +const CLASSIFY_PROMPT = `You are a UI design tool assistant. Classify the user's message intent. +Reply with EXACTLY one of these tags, nothing else: +- DESIGN — user wants to create, generate, or modify any UI element, component, screen, or page +- CHAT — user is asking a question, seeking help, or having a conversation` + +/** Classify user intent via a lightweight LLM call instead of hardcoded keyword matching */ +async function classifyIntent( + text: string, + model: string, + provider?: string, +): Promise<{ isDesign: boolean }> { + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 8_000) + + const response = await fetch('/api/ai/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + system: CLASSIFY_PROMPT, + message: text, + model, + provider, + }), + signal: controller.signal, + }) + clearTimeout(timeout) + + if (!response.ok) throw new Error('classify failed') + const data = await response.json() + const upper = (data.text ?? '').trim().toUpperCase() + + return { isDesign: upper.includes('DESIGN') } + } catch { + // Fallback: in a design tool, default to design mode + return { isDesign: true } + } } export function buildContextString(): string { @@ -96,8 +120,6 @@ export function useChatHandlers() { // Determine context and mode const selectedIds = useCanvasStore.getState().selection.selectedIds const hasSelection = selectedIds.length > 0 - const isDesign = isDesignRequest(messageText) - const isModification = isDesign && hasSelection const context = buildContextString() const fullUserMessage = messageText + context @@ -141,11 +163,19 @@ export function useChatHandlers() { let accumulated = '' let appliedCount = 0 + let isDesign = false const abortController = new AbortController() useAIStore.getState().setAbortController(abortController) try { + // Classify intent via lightweight LLM call + const classified = await classifyIntent( + messageText, model, currentProvider, + ) + isDesign = classified.isDesign + const isModification = isDesign && hasSelection + if (isDesign) { if (isModification) { // --- MODIFICATION MODE --- @@ -177,6 +207,7 @@ export function useChatHandlers() { model, provider: currentProvider, concurrency, + mode: 'visual-ref', context: { canvasSize: { width: 1200, height: 800 }, documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`, diff --git a/src/components/panels/chat-message.tsx b/src/components/panels/chat-message.tsx index 20837465..84768c11 100644 --- a/src/components/panels/chat-message.tsx +++ b/src/components/panels/chat-message.tsx @@ -143,16 +143,31 @@ export function countDesignJsonBlocks(text: string): number { return count } +export interface PipelineItem { + label: string + done: boolean + active: boolean + /** Optional detail lines (e.g. validation log) */ + details?: string[] +} + export function buildPipelineProgress( steps: ParsedStep[], jsonBlockCount: number, isStreaming: boolean, isApplied: boolean, hasError: boolean, -): Array<{ label: string; done: boolean; active: boolean }> { +): PipelineItem[] { // No steps = no checklist if (steps.length === 0) return [] + // Parse detail lines from step content (one line per entry) + function extractDetails(content: string): string[] | undefined { + if (!content) return undefined + const lines = content.split('\n').map((l) => l.trim()).filter(Boolean) + return lines.length > 0 ? lines : undefined + } + // If steps have explicit status (orchestrator mode), use that directly. // Check this BEFORE terminal result logic so that user-stopped generations // preserve the actual per-step status instead of marking everything done. @@ -162,13 +177,14 @@ export function buildPipelineProgress( label: s.title, done: s.status === 'done', active: isStreaming && s.status === 'streaming', + details: extractDetails(s.content), })) } // If generation is complete and applied, mark all steps done const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0) if (hasTerminalResult) { - return steps.map((s) => ({ label: s.title, done: true, active: false })) + return steps.map((s) => ({ label: s.title, done: true, active: false, details: extractDetails(s.content) })) } // Fallback: Map each step to done/active/pending based on completed JSON blocks. @@ -177,7 +193,7 @@ export function buildPipelineProgress( return steps.map((s, index) => { const done = index < jsonBlockCount const active = isStreaming && !done && index === jsonBlockCount - return { label: s.title, done, active } + return { label: s.title, done, active, details: extractDetails(s.content) } }) } diff --git a/src/components/panels/code-panel.tsx b/src/components/panels/code-panel.tsx index cc80d24e..2f5179ee 100644 --- a/src/components/panels/code-panel.tsx +++ b/src/components/panels/code-panel.tsx @@ -1,22 +1,95 @@ import { useState, useMemo, useCallback, useRef, useEffect } from 'react' -import { Copy, Check, X } from 'lucide-react' +import { Copy, Check, Sparkles, Loader2, RotateCcw, Download, ChevronLeft, ChevronRight } from 'lucide-react' import { cn } from '@/lib/utils' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, getActivePageChildren } from '@/stores/document-store' +import { useAIStore } from '@/stores/ai-store' +import { streamChat } from '@/services/ai/ai-service' import { generateReactCode } from '@/services/codegen/react-generator' import { generateHTMLCode } from '@/services/codegen/html-generator' +import { generateVueCode } from '@/services/codegen/vue-generator' +import { generateSvelteCode } from '@/services/codegen/svelte-generator' +import { generateSwiftUICode } from '@/services/codegen/swiftui-generator' +import { generateComposeCode } from '@/services/codegen/compose-generator' +import { generateFlutterCode } from '@/services/codegen/flutter-generator' +import { generateReactNativeCode } from '@/services/codegen/react-native-generator' import { generateCSSVariables } from '@/services/codegen/css-variables-generator' import { highlightCode } from '@/utils/syntax-highlight' import type { PenNode } from '@/types/pen' -type CodeTab = 'react' | 'html' | 'css-vars' +type CodeTab = 'react' | 'vue' | 'svelte' | 'html' | 'swiftui' | 'compose' | 'flutter' | 'react-native' | 'css-vars' -export default function CodePanel({ onClose }: { onClose: () => void }) { +const ENHANCE_SYSTEM_PROMPT = `You are a code rewriter. You receive auto-generated UI code and rewrite it to be idiomatic, production-ready, and RESPONSIVE. + +CRITICAL: Your ENTIRE response must be ONLY the improved source code. Nothing else. +- Do NOT include explanations, commentary, reasoning, or thinking. +- Do NOT include markdown fences (\`\`\`), XML tags, or tool calls. +- Do NOT prefix with "Here is" or any preamble. +- Start your response with the first line of code and end with the last line. + +Rewriting rules: +- Preserve visual fidelity — the output must look identical to the input design on desktop. +- Use semantic HTML where appropriate (nav, header, main, section, article, etc.). +- Replace absolute pixel positioning with proper layout (flexbox/grid) where possible. +- Use meaningful class/variable names derived from the node names. +- Keep the same framework and language as the input. +- For CSS-in-JS or scoped styles, keep styles co-located. +- For SwiftUI/Compose/Flutter, use idiomatic patterns (SwiftUI: adaptive stacks, Compose: adaptive layouts, Flutter: LayoutBuilder/MediaQuery). +- Do not add functionality, interactivity, or state beyond what exists. +- If design variables are present as var(--name), preserve them. + +RESPONSIVE DESIGN (CRITICAL — make the output adapt gracefully across screen sizes): +- Convert fixed pixel widths to relative units (%, max-w-*, flex-1, w-full) where appropriate. Keep max-width constraints for readability. +- Use responsive Tailwind breakpoints (sm:, md:, lg:) for layout changes: + - Multi-column rows (horizontal layouts) should stack vertically on small screens (flex-col → sm:flex-row or md:flex-row). + - Grid layouts: use responsive column counts (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3). + - Adjust padding/gap with responsive variants (p-4 md:p-8 lg:p-12). + - Font sizes: scale down on mobile (text-2xl md:text-4xl lg:text-5xl). +- Images: use w-full max-w-* with aspect ratio preservation, never fixed px width. +- Container: use max-w-7xl mx-auto px-4 (or similar) for centered content with side padding. +- Navigation: consider collapsible patterns on small screens. +- For HTML+CSS: use @media queries with mobile-first breakpoints (min-width: 640px, 768px, 1024px). +- For Vue/Svelte: apply the same responsive CSS/class strategies. +- Do NOT break the design on desktop while making it responsive — desktop must remain visually faithful.` + +/** Strip markdown fences, reasoning preamble, and tool-call XML from AI output. */ +function cleanEnhancedResult(raw: string): string { + let result = raw + + // Strip everything before the first code-like line if the AI prepended reasoning + // Detect patterns like "I'll start...", "", "Let me..." + const reasoningPatterns = /^(I['']ll |Let me |Here['']s |Sure|Okay||)/im + if (reasoningPatterns.test(result)) { + // Try to find the start of actual code after the reasoning + // Look for common code markers: import, export, <, struct, @, class, fun, 0) { + result = result.slice(codeStart) + } + } + + // Strip markdown fences + if (result.startsWith('```')) { + const firstNewline = result.indexOf('\n') + const lastFence = result.lastIndexOf('```') + if (lastFence > firstNewline) { + result = result.slice(firstNewline + 1, lastFence).trimEnd() + } + } + + return result +} + +export default function CodePanel() { const { t } = useTranslation() const [activeTab, setActiveTab] = useState('react') const [copied, setCopied] = useState(false) + const [enhancedCode, setEnhancedCode] = useState>({}) + const [isEnhancing, setIsEnhancing] = useState(false) + const enhanceAbortRef = useRef(null) const copyTimeoutRef = useRef>(null) const selectedIds = useCanvasStore((s) => s.selection.selectedIds) const activePageId = useCanvasStore((s) => s.activePageId) @@ -38,107 +111,351 @@ export default function CodePanel({ onClose }: { onClose: () => void }) { const document = useDocumentStore((s) => s.document) const generatedCode = useMemo(() => { - if (activeTab === 'css-vars') { - return generateCSSVariables(document) + switch (activeTab) { + case 'css-vars': return generateCSSVariables(document) + case 'react': return generateReactCode(targetNodes) + case 'vue': return generateVueCode(targetNodes) + case 'svelte': return generateSvelteCode(targetNodes) + case 'swiftui': return generateSwiftUICode(targetNodes) + case 'compose': return generateComposeCode(targetNodes) + case 'flutter': return generateFlutterCode(targetNodes) + case 'react-native': return generateReactNativeCode(targetNodes) + case 'html': { + const { html, css } = generateHTMLCode(targetNodes) + return `\n\n\n \n \n Design\n \n\n\n${html.split('\n').map((l) => ` ${l}`).join('\n')}\n\n` + } } - if (activeTab === 'react') { - return generateReactCode(targetNodes) - } - const { html, css } = generateHTMLCode(targetNodes) - return `\n${html}\n\n/* CSS */\n${css}` }, [activeTab, targetNodes, document]) + // Use enhanced code if available for this tab, otherwise the generated code + const displayCode = enhancedCode[activeTab] ?? generatedCode + const highlightedHTML = useMemo(() => { - if (activeTab === 'css-vars') { - return highlightCode(generatedCode, 'css') + const langMap: Record[1]> = { + react: 'jsx', + vue: 'html', + svelte: 'html', + swiftui: 'swift', + compose: 'kotlin', + flutter: 'dart', + 'react-native': 'jsx', + 'css-vars': 'css', + html: 'html', } - if (activeTab === 'react') { - return highlightCode(generatedCode, 'jsx') + // HTML / Vue / Svelte: split at ') + if (closingIdx !== -1) { + const cssContent = styleBody.slice(0, closingIdx) + const closingTag = styleBody.slice(closingIdx) + return ( + highlightCode(templatePart, 'html') + + highlightCode(styleTag, 'html') + '\n' + + highlightCode(cssContent, 'css') + + highlightCode(closingTag, 'html') + ) + } + } + return highlightCode(templatePart, 'html') + highlightCode(stylePart, 'css') + } } - // Split HTML and CSS sections for mixed highlighting - const htmlEnd = generatedCode.indexOf('\n\n/* CSS */') - if (htmlEnd === -1) return highlightCode(generatedCode, 'html') - const htmlPart = generatedCode.slice(0, htmlEnd) - const cssPart = generatedCode.slice(htmlEnd + 2) - return highlightCode(htmlPart, 'html') + '\n\n' + highlightCode(cssPart, 'css') - }, [activeTab, generatedCode]) + return highlightCode(displayCode, langMap[activeTab]) + }, [activeTab, displayCode]) const handleCopy = useCallback(() => { - navigator.clipboard.writeText(generatedCode).then(() => { + navigator.clipboard.writeText(displayCode).then(() => { setCopied(true) if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current) copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000) }) - }, [generatedCode]) + }, [displayCode]) + + const handleDownload = useCallback(() => { + const extMap: Record = { + react: 'tsx', + vue: 'vue', + svelte: 'svelte', + html: 'html', + swiftui: 'swift', + compose: 'kt', + flutter: 'dart', + 'react-native': 'tsx', + 'css-vars': 'css', + } + const ext = extMap[activeTab] + const blob = new Blob([displayCode], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = globalThis.document.createElement('a') + a.href = url + a.download = `design.${ext}` + a.click() + URL.revokeObjectURL(url) + }, [activeTab, displayCode]) + + const hasAI = useAIStore((s) => s.availableModels.length > 0) + + const handleEnhance = useCallback(async () => { + if (isEnhancing || activeTab === 'css-vars') return + + const model = useAIStore.getState().model + const modelGroups = useAIStore.getState().modelGroups + const provider = modelGroups.find((g) => + g.models.some((m) => m.value === model), + )?.provider + + if (!model || !provider) return + + setIsEnhancing(true) + const abortController = new AbortController() + enhanceAbortRef.current = abortController + + // Build a compact node summary for context + const nodesSummary = JSON.stringify( + targetNodes.map((n) => { + const base: Record = { type: n.type, name: n.name } + if ('width' in n) base.width = n.width + if ('height' in n) base.height = n.height + if ('children' in n && Array.isArray(n.children)) base.childCount = n.children.length + if ('layout' in n) base.layout = n.layout + return base + }), + ) + + const frameworkNames: Record = { + react: 'React with Tailwind CSS', + vue: 'Vue 3 SFC', + svelte: 'Svelte', + html: 'HTML + CSS', + swiftui: 'SwiftUI', + compose: 'Jetpack Compose (Kotlin)', + flutter: 'Flutter (Dart)', + 'react-native': 'React Native', + 'css-vars': '', + } + + const userMessage = `Framework: ${frameworkNames[activeTab]} + +Design nodes (JSON summary): +${nodesSummary} + +Auto-generated code to improve: +${generatedCode}` + + try { + let result = '' + for await (const chunk of streamChat( + ENHANCE_SYSTEM_PROMPT, + [{ role: 'user', content: userMessage }], + model, + { thinkingMode: 'disabled', effort: 'high' }, + provider, + abortController.signal, + )) { + if (chunk.type === 'text') { + result += chunk.content + setEnhancedCode((prev) => ({ ...prev, [activeTab]: result })) + } + if (chunk.type === 'error') break + } + // Clean up artifacts the AI may have included despite instructions + result = cleanEnhancedResult(result) + setEnhancedCode((prev) => ({ ...prev, [activeTab]: result })) + } finally { + setIsEnhancing(false) + enhanceAbortRef.current = null + } + }, [isEnhancing, activeTab, generatedCode, targetNodes]) + + const handleCancelEnhance = useCallback(() => { + enhanceAbortRef.current?.abort() + setIsEnhancing(false) + }, []) + + const handleResetEnhance = useCallback(() => { + setEnhancedCode((prev) => { + const next = { ...prev } + delete next[activeTab] + return next + }) + }, [activeTab]) + + // Clear enhanced code when nodes change + useEffect(() => { + setEnhancedCode({}) + }, [targetNodes]) useEffect(() => { return () => { if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current) + enhanceAbortRef.current?.abort() } }, []) - const tabs: { key: CodeTab; labelKey: string }[] = [ - { key: 'react', labelKey: 'code.reactTailwind' }, - { key: 'html', labelKey: 'code.htmlCss' }, - { key: 'css-vars', labelKey: 'code.cssVariables' }, + const tabs: { key: CodeTab; label: string }[] = [ + { key: 'react', label: 'React' }, + { key: 'vue', label: 'Vue' }, + { key: 'svelte', label: 'Svelte' }, + { key: 'html', label: 'HTML' }, + { key: 'swiftui', label: 'SwiftUI' }, + { key: 'compose', label: 'Compose' }, + { key: 'flutter', label: 'Flutter' }, + { key: 'react-native', label: 'RN' }, + { key: 'css-vars', label: t('code.cssVariables') }, ] + const tabsScrollRef = useRef(null) + const [canScrollLeft, setCanScrollLeft] = useState(false) + const [canScrollRight, setCanScrollRight] = useState(false) + + const updateScrollState = useCallback(() => { + const el = tabsScrollRef.current + if (!el) return + setCanScrollLeft(el.scrollLeft > 1) + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1) + }, []) + + useEffect(() => { + const el = tabsScrollRef.current + if (!el) return + updateScrollState() + el.addEventListener('scroll', updateScrollState, { passive: true }) + const ro = new ResizeObserver(updateScrollState) + ro.observe(el) + return () => { + el.removeEventListener('scroll', updateScrollState) + ro.disconnect() + } + }, [updateScrollState]) + + const scrollTabs = useCallback((dir: 'left' | 'right') => { + tabsScrollRef.current?.scrollBy({ left: dir === 'left' ? -80 : 80, behavior: 'smooth' }) + }, []) + return ( -
- {/* Header */} -
-
+
+ {/* Framework tabs + action buttons */} +
+ {canScrollLeft && ( + + )} +
{tabs.map((tab) => ( ))}
-
- - + {canScrollRight && ( + + )} +
+ {hasAI && activeTab !== 'css-vars' && ( + <> + {enhancedCode[activeTab] && !isEnhancing && ( + + + + + {t('code.resetEnhance')} + + )} + + + + + {isEnhancing ? t('code.cancelEnhance') : t('code.aiEnhance')} + + + )} + + + + + {t('code.download')} + + + + + + {copied ? t('code.copied') : t('code.copyClipboard')} +
{/* Code content */} -
-
+      
+
           
         
{/* Footer info */} -
- - {activeTab === 'css-vars' - ? t('code.genCssVars') - : selectedIds.length > 0 - ? t('code.genSelected', { count: selectedIds.length }) - : t('code.genDocument')} +
+ + {isEnhancing + ? t('code.enhancing') + : enhancedCode[activeTab] + ? t('code.enhanced') + : activeTab === 'css-vars' + ? t('code.genCssVars') + : selectedIds.length > 0 + ? t('code.genSelected', { count: selectedIds.length }) + : t('code.genDocument')}
diff --git a/src/components/panels/layer-context-menu.tsx b/src/components/panels/layer-context-menu.tsx index fb61bd90..ae4391a8 100644 --- a/src/components/panels/layer-context-menu.tsx +++ b/src/components/panels/layer-context-menu.tsx @@ -8,6 +8,9 @@ import { EyeOff, Component, Unlink, + SquaresUnite, + SquaresSubtract, + SquaresIntersect, } from 'lucide-react' interface LayerContextMenuProps { @@ -15,6 +18,7 @@ interface LayerContextMenuProps { y: number nodeId: string canGroup: boolean + canBoolean: boolean canCreateComponent: boolean isReusable: boolean isInstance: boolean @@ -26,6 +30,9 @@ const MENU_ITEMS = [ { action: 'duplicate', labelKey: 'common.duplicate', icon: Copy }, { action: 'delete', labelKey: 'common.delete', icon: Trash2 }, { action: 'group', labelKey: 'layerMenu.groupSelection', icon: Group, requireGroup: true }, + { action: 'boolean-union', labelKey: 'layerMenu.booleanUnion', icon: SquaresUnite, requireBoolean: true }, + { action: 'boolean-subtract', labelKey: 'layerMenu.booleanSubtract', icon: SquaresSubtract, requireBoolean: true }, + { action: 'boolean-intersect', labelKey: 'layerMenu.booleanIntersect', icon: SquaresIntersect, requireBoolean: true }, { action: 'make-component', labelKey: 'layerMenu.createComponent', icon: Component, requireCreateComponent: true }, { action: 'detach-component', labelKey: 'layerMenu.detachComponent', icon: Unlink, requireReusable: true }, { action: 'detach-component', labelKey: 'layerMenu.detachInstance', icon: Unlink, requireInstance: true }, @@ -37,6 +44,7 @@ export default function LayerContextMenu({ x, y, canGroup, + canBoolean, canCreateComponent, isReusable, isInstance, @@ -72,6 +80,7 @@ export default function LayerContextMenu({ {MENU_ITEMS.filter( (item) => (!item.requireGroup || canGroup) && + (!('requireBoolean' in item) || canBoolean) && (!('requireCreateComponent' in item) || canCreateComponent) && (!('requireReusable' in item) || isReusable) && (!('requireInstance' in item) || isInstance), diff --git a/src/components/panels/layer-panel.tsx b/src/components/panels/layer-panel.tsx index 7f4bb405..1ff5e586 100644 --- a/src/components/panels/layer-panel.tsx +++ b/src/components/panels/layer-panel.tsx @@ -5,6 +5,8 @@ import { useCanvasStore } from '@/stores/canvas-store' import { setSkipNextDepthResolve } from '@/canvas/use-canvas-selection' import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory' import type { PenNode } from '@/types/pen' +import { useHistoryStore } from '@/stores/history-store' +import { canBooleanOp, executeBooleanOp, type BooleanOpType } from '@/utils/boolean-ops' import LayerItem from './layer-item' import type { DropPosition } from './layer-item' import LayerContextMenu from './layer-context-menu' @@ -12,6 +14,10 @@ import PageTabs from '@/components/editor/page-tabs' const CONTAINER_TYPES = new Set(['frame', 'group', 'ref']) +const LAYER_MIN_WIDTH = 180 +const LAYER_MAX_WIDTH = 480 +const LAYER_DEFAULT_WIDTH = 224 // w-56 + interface DragState { dragId: string | null overId: string | null @@ -121,6 +127,38 @@ function collectCollapsibleNodeIds( export default function LayerPanel() { const { t } = useTranslation() + const [panelWidth, setPanelWidth] = useState(LAYER_DEFAULT_WIDTH) + const isDraggingResize = useRef(false) + const resizeStartX = useRef(0) + const resizeStartWidth = useRef(0) + + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + isDraggingResize.current = true + resizeStartX.current = e.clientX + resizeStartWidth.current = panelWidth + + const handleMouseMove = (ev: MouseEvent) => { + if (!isDraggingResize.current) return + const delta = ev.clientX - resizeStartX.current + const newWidth = Math.max(LAYER_MIN_WIDTH, Math.min(LAYER_MAX_WIDTH, resizeStartWidth.current + delta)) + setPanelWidth(newWidth) + } + + const handleMouseUp = () => { + isDraggingResize.current = false + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [panelWidth]) + const activePageId = useCanvasStore((s) => s.activePageId) const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId)) const updateNode = useDocumentStore((s) => s.updateNode) @@ -355,6 +393,24 @@ export default function LayerPanel() { case 'detach-component': detachComponent(nodeId) break + case 'boolean-union': + case 'boolean-subtract': + case 'boolean-intersect': { + const opType = action.replace('boolean-', '') as BooleanOpType + const nodes = selectedIds + .map((id) => getNodeById(id)) + .filter((n): n is PenNode => n != null) + if (canBooleanOp(nodes)) { + const result = executeBooleanOp(nodes, opType) + if (result) { + useHistoryStore.getState().pushState(useDocumentStore.getState().document) + for (const id of selectedIds) removeNode(id) + useDocumentStore.getState().addNode(null, result) + setSelection([result.id], result.id) + } + } + break + } } setContextMenu(null) }, @@ -369,6 +425,7 @@ export default function LayerPanel() { setSelection, makeReusable, detachComponent, + getNodeById, ], ) @@ -385,7 +442,12 @@ export default function LayerPanel() { } return ( -
+
+ {/* Resize handle */} +
@@ -420,12 +482,16 @@ export default function LayerPanel() { ? 'reusable' in contextNode && contextNode.reusable === true : false const nodeIsInstance = contextNode?.type === 'ref' + const booleanNodes = selectedIds + .map((id) => getNodeById(id)) + .filter((n): n is PenNode => n != null) return ( = 2} + canBoolean={canBooleanOp(booleanNodes)} canCreateComponent={isContainer && !nodeIsReusable} isReusable={nodeIsReusable} isInstance={nodeIsInstance} diff --git a/src/components/panels/property-panel.tsx b/src/components/panels/property-panel.tsx index 5ab90afe..e63cb2e2 100644 --- a/src/components/panels/property-panel.tsx +++ b/src/components/panels/property-panel.tsx @@ -24,7 +24,7 @@ const INSTANCE_DIRECT_PROPS = new Set([ 'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme', ]) -export default function PropertyPanel() { +export default function PropertyPanel({ embedded }: { embedded?: boolean } = {}) { const { t } = useTranslation() const activeId = useCanvasStore((s) => s.selection.activeId) const setSelection = useCanvasStore((s) => s.setSelection) @@ -158,10 +158,10 @@ export default function PropertyPanel() { } } - return ( -
+ const content = ( + <> {/* Header */} -
+
{(nodeIsReusable || nodeIsInstance) && ( )} @@ -364,6 +364,14 @@ export default function PropertyPanel() {
+ + ) + + if (embedded) return content + + return ( +
+ {content}
) } diff --git a/src/components/panels/right-panel.tsx b/src/components/panels/right-panel.tsx new file mode 100644 index 00000000..72262bd6 --- /dev/null +++ b/src/components/panels/right-panel.tsx @@ -0,0 +1,90 @@ +import { useState, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { useCanvasStore } from '@/stores/canvas-store' +import type { RightPanelTab } from '@/stores/canvas-store' +import PropertyPanel from './property-panel' +import CodePanel from './code-panel' + +const MIN_WIDTH = 256 // 16rem (w-64) +const MAX_WIDTH = 640 // 40rem +const DEFAULT_WIDTH = 256 + +export default function RightPanel() { + const { t } = useTranslation() + const activeTab = useCanvasStore((s) => s.rightPanelTab) + const setTab = useCanvasStore((s) => s.setRightPanelTab) + const [width, setWidth] = useState(DEFAULT_WIDTH) + const isDragging = useRef(false) + const startX = useRef(0) + const startWidth = useRef(0) + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault() + isDragging.current = true + startX.current = e.clientX + startWidth.current = width + + const handleMouseMove = (ev: MouseEvent) => { + if (!isDragging.current) return + // Dragging left border: moving mouse left => wider + const delta = startX.current - ev.clientX + const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startWidth.current + delta)) + setWidth(newWidth) + } + + const handleMouseUp = () => { + isDragging.current = false + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [width]) + + const tabs: { key: RightPanelTab; label: string }[] = [ + { key: 'design', label: t('rightPanel.design') }, + { key: 'code', label: t('rightPanel.code') }, + ] + + return ( +
+ {/* Resize handle */} +
+ + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Content */} + {activeTab === 'design' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/src/components/shared/agent-settings-dialog.tsx b/src/components/shared/agent-settings-dialog.tsx index e186288b..3ab40e2c 100644 --- a/src/components/shared/agent-settings-dialog.tsx +++ b/src/components/shared/agent-settings-dialog.tsx @@ -211,7 +211,7 @@ function ProviderRow({ type }: { type: AIProviderType }) {
- +
{/* Name + description */} @@ -305,7 +305,7 @@ export default function AgentSettingsDialog() { const [mcpError, setMcpError] = useState(null) const [mcpServerLoading, setMcpServerLoading] = useState(false) const [mcpServerError, setMcpServerError] = useState(null) - const [copied, setCopied] = useState(false) + const [configCopied, setConfigCopied] = useState(false) const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true) const [isElectron, setIsElectron] = useState(false) @@ -364,11 +364,16 @@ export default function AgentSettingsDialog() { } }, [mcpServerRunning, mcpHttpPort, setMcpServerStatus, t]) - const handleCopyLanUrl = useCallback(() => { + const handleCopyConfig = useCallback(() => { if (!mcpServerLocalIp) return - navigator.clipboard.writeText(`http://${mcpServerLocalIp}:${mcpHttpPort}/mcp`) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + const config = JSON.stringify( + { type: 'http', url: `http://${mcpServerLocalIp}:${mcpHttpPort}/mcp` }, + null, + 2, + ) + navigator.clipboard.writeText(config) + setConfigCopied(true) + setTimeout(() => setConfigCopied(false), 2000) }, [mcpServerLocalIp, mcpHttpPort]) const handleToggleMCP = useCallback( @@ -421,7 +426,7 @@ export default function AgentSettingsDialog() { className="relative bg-card rounded-xl border border-border w-[480px] max-h-[80vh] overflow-hidden shadow-xl flex flex-col" > {/* Header */} -
+

{t('agents.title')}

@@ -435,10 +440,10 @@ export default function AgentSettingsDialog() {
{/* Scrollable content */} -
+
{/* Agents section */} -
-
+
+

{t('agents.agentsOnCanvas')} @@ -453,17 +458,17 @@ export default function AgentSettingsDialog() {

{/* Divider */} -
+
{/* MCP Server section */} -
-
+
+

{t('agents.mcpServer')}

-
+
{/* Status indicator */}
{mcpServerRunning && mcpServerLocalIp && ( -
- {t('agents.mcpLanAccess')} - - http://{mcpServerLocalIp}:{mcpHttpPort}/mcp - - +
+
+ {t('agents.mcpClientConfig')} + +
+ {`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}
)} {mcpServerError && ( @@ -531,23 +536,23 @@ export default function AgentSettingsDialog() {
{/* Divider */} -
+
{/* MCP integrations section */}
-
+

{t('agents.mcpIntegrations')}

-
+
{mcpIntegrations.map((m) => (
@@ -579,7 +584,7 @@ export default function AgentSettingsDialog() {

{mcpError}

)} -

+

{t('agents.mcpRestart')}

@@ -587,7 +592,7 @@ export default function AgentSettingsDialog() { {/* Auto-update toggle (Electron only) */} {isElectron && ( <> -
+
diff --git a/src/hooks/use-electron-menu.ts b/src/hooks/use-electron-menu.ts index 7b2023e4..fad9e2e4 100644 --- a/src/hooks/use-electron-menu.ts +++ b/src/hooks/use-electron-menu.ts @@ -4,6 +4,7 @@ import { useDocumentStore } from '@/stores/document-store' import { useHistoryStore } from '@/stores/history-store' import { zoomToFitContent } from '@/canvas/use-fabric-canvas' import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync' +import { normalizePenDocument } from '@/utils/normalize-pen-file' import { supportsFileSystemAccess, writeToFileHandle, @@ -22,6 +23,29 @@ export function useElectronMenu() { const api = window.electronAPI if (!api?.onMenuAction) return + const loadFileFromPath = (filePath: string) => { + api.readFile?.(filePath).then((result) => { + if (!result) return + try { + const raw = JSON.parse(result.content) + if (!raw.version || !Array.isArray(raw.children)) return + const doc = normalizePenDocument(raw) + const name = filePath.split(/[/\\]/).pop() || 'untitled.op' + useDocumentStore.getState().loadDocument(doc, name) + requestAnimationFrame(() => zoomToFitContent()) + } catch { + // Invalid file — ignore + } + }) + } + + const cleanupOpenFile = api.onOpenFile?.(loadFileFromPath) + + // Pull any pending file from cold start (double-click .op to launch app) + api.getPendingFile?.().then((filePath) => { + if (filePath) loadFileFromPath(filePath) + }) + const cleanup = api.onMenuAction((action: string) => { switch (action) { case 'new': @@ -113,6 +137,9 @@ export function useElectronMenu() { } }) - return cleanup + return () => { + cleanup() + cleanupOpenFile?.() + } }, []) } diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 8d94bda0..0c616f23 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, getActivePageChildren } from '@/stores/document-store' import { useHistoryStore } from '@/stores/history-store' import { cloneNodesWithNewIds } from '@/utils/node-clone' +import { canBooleanOp, executeBooleanOp, type BooleanOpType } from '@/utils/boolean-ops' import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste' import { supportsFileSystemAccess, @@ -267,6 +268,40 @@ export function useKeyboardShortcuts() { return } + // Boolean operations: Cmd/Ctrl+Alt+U (union), Cmd/Ctrl+Alt+S (subtract), Cmd/Ctrl+Alt+I (intersect) + if (isMod && e.altKey && !e.shiftKey) { + const booleanOps: Record = { + u: 'union', + s: 'subtract', + i: 'intersect', + } + const opType = booleanOps[e.key.toLowerCase()] + if (opType) { + const { selectedIds } = useCanvasStore.getState().selection + const nodes = selectedIds + .map((id) => useDocumentStore.getState().getNodeById(id)) + .filter((n): n is NonNullable => n != null) + if (canBooleanOp(nodes)) { + e.preventDefault() + const result = executeBooleanOp(nodes, opType) + if (result) { + useHistoryStore.getState().pushState(useDocumentStore.getState().document) + for (const id of selectedIds) { + useDocumentStore.getState().removeNode(id) + } + useDocumentStore.getState().addNode(null, result) + useCanvasStore.getState().setSelection([result.id], result.id) + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas) { + canvas.discardActiveObject() + canvas.requestRenderAll() + } + } + } + return + } + } + // Tool shortcuts (single key, no modifier) if (!isMod && !e.shiftKey && !e.altKey) { const tool = TOOL_KEYS[e.key.toLowerCase()] diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index e82ce3d4..be84a837 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -44,6 +44,7 @@ const de: TranslationKeys = { 'topbar.open': 'Öffnen', 'topbar.save': 'Speichern', 'topbar.importFigma': 'Figma importieren', + 'topbar.codePanel': 'Code', 'topbar.lightMode': 'Heller Modus', 'topbar.darkMode': 'Dunkler Modus', 'topbar.fullscreen': 'Vollbild', @@ -54,6 +55,11 @@ const de: TranslationKeys = { 'topbar.connected': 'verbunden', 'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Design', + 'rightPanel.code': 'Code', + 'rightPanel.noSelection': 'Element auswählen', + // ── Pages ── 'pages.title': 'Seiten', 'pages.addPage': 'Seite hinzufügen', @@ -107,6 +113,9 @@ const de: TranslationKeys = { 'layerMenu.createComponent': 'Komponente erstellen', 'layerMenu.detachComponent': 'Komponente lösen', 'layerMenu.detachInstance': 'Instanz lösen', + 'layerMenu.booleanUnion': 'Vereinigung', + 'layerMenu.booleanSubtract': 'Subtraktion', + 'layerMenu.booleanIntersect': 'Schnittmenge', 'layerMenu.toggleLock': 'Sperren umschalten', 'layerMenu.toggleVisibility': 'Sichtbarkeit umschalten', @@ -266,11 +275,18 @@ const de: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'In Zwischenablage kopieren', + 'code.copied': 'Kopiert!', + 'code.download': 'Code-Datei herunterladen', 'code.closeCodePanel': 'Code-Panel schließen', 'code.genCssVars': 'CSS-Variablen für das gesamte Dokument generieren', 'code.genSelected': 'Code für {{count}} ausgewählte(s) Element(e) generieren', 'code.genDocument': 'Code für das gesamte Dokument generieren', + 'code.aiEnhance': 'KI-Verbesserung', + 'code.cancelEnhance': 'Verbesserung abbrechen', + 'code.resetEnhance': 'Zurücksetzen', + 'code.enhancing': 'KI verbessert den Code...', + 'code.enhanced': 'Von KI verbessert', // ── Save Dialog ── 'save.saveAs': 'Speichern unter', @@ -305,6 +321,7 @@ const de: TranslationKeys = { 'agents.mcpServerRunning': 'Läuft', 'agents.mcpServerStopped': 'Gestoppt', 'agents.mcpLanAccess': 'LAN-Zugriff', + 'agents.mcpClientConfig': 'Client-Konfiguration', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d3226057..5ca53ccc 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -42,6 +42,7 @@ const en = { 'topbar.open': 'Open', 'topbar.save': 'Save', 'topbar.importFigma': 'Import Figma', + 'topbar.codePanel': 'Code', 'topbar.lightMode': 'Light mode', 'topbar.darkMode': 'Dark mode', 'topbar.fullscreen': 'Fullscreen', @@ -52,6 +53,11 @@ const en = { 'topbar.connected': 'connected', 'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Design', + 'rightPanel.code': 'Code', + 'rightPanel.noSelection': 'Select an element', + // ── Pages ── 'pages.title': 'Pages', 'pages.addPage': 'Add page', @@ -103,6 +109,9 @@ const en = { 'layerMenu.createComponent': 'Create Component', 'layerMenu.detachComponent': 'Detach Component', 'layerMenu.detachInstance': 'Detach Instance', + 'layerMenu.booleanUnion': 'Union', + 'layerMenu.booleanSubtract': 'Subtract', + 'layerMenu.booleanIntersect': 'Intersect', 'layerMenu.toggleLock': 'Toggle Lock', 'layerMenu.toggleVisibility': 'Toggle Visibility', @@ -262,11 +271,18 @@ const en = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Copy to clipboard', + 'code.copied': 'Copied!', + 'code.download': 'Download code file', 'code.closeCodePanel': 'Close code panel', 'code.genCssVars': 'Generating CSS variables for entire document', 'code.genSelected': 'Generating code for {{count}} selected element(s)', 'code.genDocument': 'Generating code for entire document', + 'code.aiEnhance': 'AI Enhance', + 'code.cancelEnhance': 'Cancel enhancement', + 'code.resetEnhance': 'Reset to original', + 'code.enhancing': 'AI is enhancing code...', + 'code.enhanced': 'Enhanced by AI', // ── Save Dialog ── 'save.saveAs': 'Save As', @@ -301,6 +317,7 @@ const en = { 'agents.mcpServerRunning': 'Running', 'agents.mcpServerStopped': 'Stopped', 'agents.mcpLanAccess': 'LAN Access', + 'agents.mcpClientConfig': 'Client Config', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9fb95f5a..bf73d60f 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -44,6 +44,7 @@ const es: TranslationKeys = { 'topbar.open': 'Abrir', 'topbar.save': 'Guardar', 'topbar.importFigma': 'Importar Figma', + 'topbar.codePanel': 'Código', 'topbar.lightMode': 'Modo claro', 'topbar.darkMode': 'Modo oscuro', 'topbar.fullscreen': 'Pantalla completa', @@ -54,6 +55,11 @@ const es: TranslationKeys = { 'topbar.connected': 'conectado', 'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Diseño', + 'rightPanel.code': 'Código', + 'rightPanel.noSelection': 'Selecciona un elemento', + // ── Pages ── 'pages.title': 'Páginas', 'pages.addPage': 'Agregar página', @@ -107,6 +113,9 @@ const es: TranslationKeys = { 'layerMenu.createComponent': 'Crear componente', 'layerMenu.detachComponent': 'Separar componente', 'layerMenu.detachInstance': 'Separar instancia', + 'layerMenu.booleanUnion': 'Unión', + 'layerMenu.booleanSubtract': 'Restar', + 'layerMenu.booleanIntersect': 'Intersección', 'layerMenu.toggleLock': 'Alternar bloqueo', 'layerMenu.toggleVisibility': 'Alternar visibilidad', @@ -270,12 +279,19 @@ const es: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Copiar al portapapeles', + 'code.copied': '¡Copiado!', + 'code.download': 'Descargar archivo de código', 'code.closeCodePanel': 'Cerrar panel de código', 'code.genCssVars': 'Generando variables CSS para todo el documento', 'code.genSelected': 'Generando código para {{count}} elemento(s) seleccionado(s)', 'code.genDocument': 'Generando código para todo el documento', + 'code.aiEnhance': 'Mejorar con IA', + 'code.cancelEnhance': 'Cancelar mejora', + 'code.resetEnhance': 'Restablecer original', + 'code.enhancing': 'La IA está mejorando el código...', + 'code.enhanced': 'Mejorado por IA', // ── Save Dialog ── 'save.saveAs': 'Guardar como', @@ -310,6 +326,7 @@ const es: TranslationKeys = { 'agents.mcpServerRunning': 'En ejecución', 'agents.mcpServerStopped': 'Detenido', 'agents.mcpLanAccess': 'Acceso LAN', + 'agents.mcpClientConfig': 'Config. del cliente', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index c9e183f6..a443ad2e 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -44,6 +44,7 @@ const fr: TranslationKeys = { 'topbar.open': 'Ouvrir', 'topbar.save': 'Enregistrer', 'topbar.importFigma': 'Importer Figma', + 'topbar.codePanel': 'Code', 'topbar.lightMode': 'Mode clair', 'topbar.darkMode': 'Mode sombre', 'topbar.fullscreen': 'Plein écran', @@ -54,6 +55,11 @@ const fr: TranslationKeys = { 'topbar.connected': 'connecté', 'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Design', + 'rightPanel.code': 'Code', + 'rightPanel.noSelection': 'Sélectionnez un élément', + // ── Pages ── 'pages.title': 'Pages', 'pages.addPage': 'Ajouter une page', @@ -107,6 +113,9 @@ const fr: TranslationKeys = { 'layerMenu.createComponent': 'Créer un composant', 'layerMenu.detachComponent': 'Détacher le composant', 'layerMenu.detachInstance': 'Détacher l\u2019instance', + 'layerMenu.booleanUnion': 'Union', + 'layerMenu.booleanSubtract': 'Soustraire', + 'layerMenu.booleanIntersect': 'Intersection', 'layerMenu.toggleLock': 'Verrouiller / Déverrouiller', 'layerMenu.toggleVisibility': 'Afficher / Masquer', @@ -268,12 +277,19 @@ const fr: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Copier dans le presse-papiers', + 'code.copied': 'Copié !', + 'code.download': 'Télécharger le fichier de code', 'code.closeCodePanel': 'Fermer le panneau de code', 'code.genCssVars': 'Génération des variables CSS pour l\u2019ensemble du document', 'code.genSelected': 'Génération du code pour {{count}} élément(s) sélectionné(s)', 'code.genDocument': 'Génération du code pour l\u2019ensemble du document', + 'code.aiEnhance': 'Améliorer par IA', + 'code.cancelEnhance': 'Annuler l\u2019amélioration', + 'code.resetEnhance': 'Réinitialiser', + 'code.enhancing': 'L\u2019IA améliore le code...', + 'code.enhanced': 'Amélioré par IA', // ── Save Dialog ── 'save.saveAs': 'Enregistrer sous', @@ -308,6 +324,7 @@ const fr: TranslationKeys = { 'agents.mcpServerRunning': 'En cours', 'agents.mcpServerStopped': 'Arrêté', 'agents.mcpLanAccess': 'Accès LAN', + 'agents.mcpClientConfig': 'Config. client', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/hi.ts b/src/i18n/locales/hi.ts index b58a14ac..e3c09609 100644 --- a/src/i18n/locales/hi.ts +++ b/src/i18n/locales/hi.ts @@ -44,6 +44,7 @@ const hi: TranslationKeys = { 'topbar.open': 'खोलें', 'topbar.save': 'सहेजें', 'topbar.importFigma': 'Figma आयात करें', + 'topbar.codePanel': 'कोड', 'topbar.lightMode': 'लाइट मोड', 'topbar.darkMode': 'डार्क मोड', 'topbar.fullscreen': 'पूर्ण स्क्रीन', @@ -54,6 +55,11 @@ const hi: TranslationKeys = { 'topbar.connected': 'कनेक्टेड', 'topbar.agentStatus': '{{agents}} एजेंट{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'डिज़ाइन', + 'rightPanel.code': 'कोड', + 'rightPanel.noSelection': 'एक तत्व चुनें', + // ── Pages ── 'pages.title': 'पेज', 'pages.addPage': 'पेज जोड़ें', @@ -105,6 +111,9 @@ const hi: TranslationKeys = { 'layerMenu.createComponent': 'कंपोनेंट बनाएँ', 'layerMenu.detachComponent': 'कंपोनेंट अलग करें', 'layerMenu.detachInstance': 'इंस्टेंस अलग करें', + 'layerMenu.booleanUnion': 'संयोजन', + 'layerMenu.booleanSubtract': 'घटाना', + 'layerMenu.booleanIntersect': 'प्रतिच्छेदन', 'layerMenu.toggleLock': 'लॉक टॉगल करें', 'layerMenu.toggleVisibility': 'दृश्यता टॉगल करें', @@ -264,11 +273,18 @@ const hi: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'क्लिपबोर्ड पर कॉपी करें', + 'code.copied': 'कॉपी हो गया!', + 'code.download': 'कोड फ़ाइल डाउनलोड करें', 'code.closeCodePanel': 'कोड पैनल बंद करें', 'code.genCssVars': 'संपूर्ण डॉक्यूमेंट के लिए CSS वेरिएबल जनरेट हो रहे हैं', 'code.genSelected': '{{count}} चयनित तत्व(ओं) के लिए कोड जनरेट हो रहा है', 'code.genDocument': 'संपूर्ण डॉक्यूमेंट के लिए कोड जनरेट हो रहा है', + 'code.aiEnhance': 'AI से सुधारें', + 'code.cancelEnhance': 'सुधार रद्द करें', + 'code.resetEnhance': 'मूल पर वापस जाएं', + 'code.enhancing': 'AI कोड सुधार रहा है...', + 'code.enhanced': 'AI द्वारा सुधारा गया', // ── Save Dialog ── 'save.saveAs': 'इस रूप में सहेजें', @@ -303,6 +319,7 @@ const hi: TranslationKeys = { 'agents.mcpServerRunning': 'चल रहा है', 'agents.mcpServerStopped': 'रुका हुआ', 'agents.mcpLanAccess': 'LAN एक्सेस', + 'agents.mcpClientConfig': 'क्लाइंट कॉन्फ़िग', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/id.ts b/src/i18n/locales/id.ts index 262d62cc..2009f42f 100644 --- a/src/i18n/locales/id.ts +++ b/src/i18n/locales/id.ts @@ -44,6 +44,7 @@ const id: TranslationKeys = { 'topbar.open': 'Buka', 'topbar.save': 'Simpan', 'topbar.importFigma': 'Impor Figma', + 'topbar.codePanel': 'Kode', 'topbar.lightMode': 'Mode terang', 'topbar.darkMode': 'Mode gelap', 'topbar.fullscreen': 'Layar penuh', @@ -54,6 +55,11 @@ const id: TranslationKeys = { 'topbar.connected': 'terhubung', 'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Desain', + 'rightPanel.code': 'Kode', + 'rightPanel.noSelection': 'Pilih sebuah elemen', + // ── Pages ── 'pages.title': 'Halaman', 'pages.addPage': 'Tambah halaman', @@ -105,6 +111,9 @@ const id: TranslationKeys = { 'layerMenu.createComponent': 'Buat Komponen', 'layerMenu.detachComponent': 'Lepaskan Komponen', 'layerMenu.detachInstance': 'Lepaskan Instance', + 'layerMenu.booleanUnion': 'Gabungan', + 'layerMenu.booleanSubtract': 'Kurangi', + 'layerMenu.booleanIntersect': 'Irisan', 'layerMenu.toggleLock': 'Alihkan Kunci', 'layerMenu.toggleVisibility': 'Alihkan Visibilitas', @@ -264,11 +273,18 @@ const id: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Salin ke papan klip', + 'code.copied': 'Tersalin!', + 'code.download': 'Unduh file kode', 'code.closeCodePanel': 'Tutup panel kode', 'code.genCssVars': 'Membuat CSS variables untuk seluruh dokumen', 'code.genSelected': 'Membuat kode untuk {{count}} elemen yang dipilih', 'code.genDocument': 'Membuat kode untuk seluruh dokumen', + 'code.aiEnhance': 'Tingkatkan dengan AI', + 'code.cancelEnhance': 'Batalkan peningkatan', + 'code.resetEnhance': 'Kembalikan ke asli', + 'code.enhancing': 'AI sedang meningkatkan kode...', + 'code.enhanced': 'Ditingkatkan oleh AI', // ── Save Dialog ── 'save.saveAs': 'Simpan Sebagai', @@ -303,6 +319,7 @@ const id: TranslationKeys = { 'agents.mcpServerRunning': 'Berjalan', 'agents.mcpServerStopped': 'Berhenti', 'agents.mcpLanAccess': 'Akses LAN', + 'agents.mcpClientConfig': 'Konfigurasi klien', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/ja.ts b/src/i18n/locales/ja.ts index bc3fb5a2..e4f2a4d0 100644 --- a/src/i18n/locales/ja.ts +++ b/src/i18n/locales/ja.ts @@ -44,6 +44,7 @@ const ja: TranslationKeys = { 'topbar.open': '開く', 'topbar.save': '保存', 'topbar.importFigma': 'Figma をインポート', + 'topbar.codePanel': 'コード', 'topbar.lightMode': 'ライトモード', 'topbar.darkMode': 'ダークモード', 'topbar.fullscreen': 'フルスクリーン', @@ -54,6 +55,11 @@ const ja: TranslationKeys = { 'topbar.connected': '接続済み', 'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'デザイン', + 'rightPanel.code': 'コード', + 'rightPanel.noSelection': '要素を選択してください', + // ── Pages ── 'pages.title': 'ページ', 'pages.addPage': 'ページを追加', @@ -109,6 +115,9 @@ const ja: TranslationKeys = { 'layerMenu.createComponent': 'コンポーネントを作成', 'layerMenu.detachComponent': 'コンポーネントを解除', 'layerMenu.detachInstance': 'インスタンスを解除', + 'layerMenu.booleanUnion': '合体', + 'layerMenu.booleanSubtract': '前面で型抜き', + 'layerMenu.booleanIntersect': '交差', 'layerMenu.toggleLock': 'ロックの切り替え', 'layerMenu.toggleVisibility': '表示の切り替え', @@ -268,10 +277,17 @@ const ja: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'クリップボードにコピー', + 'code.copied': 'コピーしました!', + 'code.download': 'コードファイルをダウンロード', 'code.closeCodePanel': 'コードパネルを閉じる', 'code.genCssVars': 'ドキュメント全体の CSS 変数を生成中', 'code.genSelected': '{{count}} 個の選択要素のコードを生成中', 'code.genDocument': 'ドキュメント全体のコードを生成中', + 'code.aiEnhance': 'AI で改善', + 'code.cancelEnhance': '改善をキャンセル', + 'code.resetEnhance': '元に戻す', + 'code.enhancing': 'AI がコードを改善中...', + 'code.enhanced': 'AI により改善済み', // ── Save Dialog ── 'save.saveAs': '名前を付けて保存', @@ -306,6 +322,7 @@ const ja: TranslationKeys = { 'agents.mcpServerRunning': '実行中', 'agents.mcpServerStopped': '停止中', 'agents.mcpLanAccess': 'LAN アクセス', + 'agents.mcpClientConfig': 'クライアント設定', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/ko.ts b/src/i18n/locales/ko.ts index f5dc0fdc..dd437972 100644 --- a/src/i18n/locales/ko.ts +++ b/src/i18n/locales/ko.ts @@ -44,6 +44,7 @@ const ko: TranslationKeys = { 'topbar.open': '열기', 'topbar.save': '저장', 'topbar.importFigma': 'Figma 가져오기', + 'topbar.codePanel': '코드', 'topbar.lightMode': '라이트 모드', 'topbar.darkMode': '다크 모드', 'topbar.fullscreen': '전체 화면', @@ -54,6 +55,11 @@ const ko: TranslationKeys = { 'topbar.connected': '연결됨', 'topbar.agentStatus': '에이전트 {{agents}}개{{agentSuffix}} · MCP {{mcp}}개', + // ── Right Panel ── + 'rightPanel.design': '디자인', + 'rightPanel.code': '코드', + 'rightPanel.noSelection': '요소를 선택하세요', + // ── Pages ── 'pages.title': '페이지', 'pages.addPage': '페이지 추가', @@ -105,6 +111,9 @@ const ko: TranslationKeys = { 'layerMenu.createComponent': '컴포넌트 만들기', 'layerMenu.detachComponent': '컴포넌트 분리', 'layerMenu.detachInstance': '인스턴스 분리', + 'layerMenu.booleanUnion': '합치기', + 'layerMenu.booleanSubtract': '빼기', + 'layerMenu.booleanIntersect': '교차', 'layerMenu.toggleLock': '잠금 전환', 'layerMenu.toggleVisibility': '표시 전환', @@ -264,11 +273,18 @@ const ko: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': '클립보드에 복사', + 'code.copied': '복사됨!', + 'code.download': '코드 파일 다운로드', 'code.closeCodePanel': '코드 패널 닫기', 'code.genCssVars': '전체 문서의 CSS 변수를 생성 중', 'code.genSelected': '선택한 요소 {{count}}개의 코드를 생성 중', 'code.genDocument': '전체 문서의 코드를 생성 중', + 'code.aiEnhance': 'AI 개선', + 'code.cancelEnhance': '개선 취소', + 'code.resetEnhance': '원본으로 복원', + 'code.enhancing': 'AI가 코드를 개선 중...', + 'code.enhanced': 'AI로 개선됨', // ── Save Dialog ── 'save.saveAs': '다른 이름으로 저장', @@ -303,6 +319,7 @@ const ko: TranslationKeys = { 'agents.mcpServerRunning': '실행 중', 'agents.mcpServerStopped': '정지됨', 'agents.mcpLanAccess': 'LAN 접근', + 'agents.mcpClientConfig': '클라이언트 설정', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/pt.ts b/src/i18n/locales/pt.ts index 6be11fe6..cae9af33 100644 --- a/src/i18n/locales/pt.ts +++ b/src/i18n/locales/pt.ts @@ -44,6 +44,7 @@ const pt: TranslationKeys = { 'topbar.open': 'Abrir', 'topbar.save': 'Salvar', 'topbar.importFigma': 'Importar Figma', + 'topbar.codePanel': 'Código', 'topbar.lightMode': 'Modo claro', 'topbar.darkMode': 'Modo escuro', 'topbar.fullscreen': 'Tela cheia', @@ -54,6 +55,11 @@ const pt: TranslationKeys = { 'topbar.connected': 'conectado', 'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Design', + 'rightPanel.code': 'Código', + 'rightPanel.noSelection': 'Selecione um elemento', + // ── Pages ── 'pages.title': 'Páginas', 'pages.addPage': 'Adicionar página', @@ -107,6 +113,9 @@ const pt: TranslationKeys = { 'layerMenu.createComponent': 'Criar componente', 'layerMenu.detachComponent': 'Desanexar componente', 'layerMenu.detachInstance': 'Desanexar instância', + 'layerMenu.booleanUnion': 'União', + 'layerMenu.booleanSubtract': 'Subtrair', + 'layerMenu.booleanIntersect': 'Interseção', 'layerMenu.toggleLock': 'Alternar bloqueio', 'layerMenu.toggleVisibility': 'Alternar visibilidade', @@ -266,11 +275,18 @@ const pt: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Copiar para a área de transferência', + 'code.copied': 'Copiado!', + 'code.download': 'Baixar arquivo de código', 'code.closeCodePanel': 'Fechar painel de código', 'code.genCssVars': 'Gerando variáveis CSS para o documento inteiro', 'code.genSelected': 'Gerando código para {{count}} elemento(s) selecionado(s)', 'code.genDocument': 'Gerando código para o documento inteiro', + 'code.aiEnhance': 'Melhorar com IA', + 'code.cancelEnhance': 'Cancelar melhoria', + 'code.resetEnhance': 'Restaurar original', + 'code.enhancing': 'A IA está melhorando o código...', + 'code.enhanced': 'Melhorado por IA', // ── Save Dialog ── 'save.saveAs': 'Salvar como', @@ -305,6 +321,7 @@ const pt: TranslationKeys = { 'agents.mcpServerRunning': 'Em execução', 'agents.mcpServerStopped': 'Parado', 'agents.mcpLanAccess': 'Acesso LAN', + 'agents.mcpClientConfig': 'Config. do cliente', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 2e7aa498..70c76343 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -44,6 +44,7 @@ const ru: TranslationKeys = { 'topbar.open': 'Открыть', 'topbar.save': 'Сохранить', 'topbar.importFigma': 'Импорт из Figma', + 'topbar.codePanel': 'Код', 'topbar.lightMode': 'Светлая тема', 'topbar.darkMode': 'Тёмная тема', 'topbar.fullscreen': 'Полный экран', @@ -54,6 +55,11 @@ const ru: TranslationKeys = { 'topbar.connected': 'подключено', 'topbar.agentStatus': '{{agents}} агент{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Дизайн', + 'rightPanel.code': 'Код', + 'rightPanel.noSelection': 'Выберите элемент', + // ── Pages ── 'pages.title': 'Страницы', 'pages.addPage': 'Добавить страницу', @@ -107,6 +113,9 @@ const ru: TranslationKeys = { 'layerMenu.createComponent': 'Создать компонент', 'layerMenu.detachComponent': 'Отсоединить компонент', 'layerMenu.detachInstance': 'Отсоединить экземпляр', + 'layerMenu.booleanUnion': 'Объединение', + 'layerMenu.booleanSubtract': 'Вычитание', + 'layerMenu.booleanIntersect': 'Пересечение', 'layerMenu.toggleLock': 'Переключить блокировку', 'layerMenu.toggleVisibility': 'Переключить видимость', @@ -266,11 +275,18 @@ const ru: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Копировать в буфер обмена', + 'code.copied': 'Скопировано!', + 'code.download': 'Скачать файл с кодом', 'code.closeCodePanel': 'Закрыть панель кода', 'code.genCssVars': 'Генерация CSS-переменных для всего документа', 'code.genSelected': 'Генерация кода для {{count}} выделенных элементов', 'code.genDocument': 'Генерация кода для всего документа', + 'code.aiEnhance': 'Улучшить с ИИ', + 'code.cancelEnhance': 'Отменить улучшение', + 'code.resetEnhance': 'Сбросить', + 'code.enhancing': 'ИИ улучшает код...', + 'code.enhanced': 'Улучшено ИИ', // ── Save Dialog ── 'save.saveAs': 'Сохранить как', @@ -305,6 +321,7 @@ const ru: TranslationKeys = { 'agents.mcpServerRunning': 'Работает', 'agents.mcpServerStopped': 'Остановлен', 'agents.mcpLanAccess': 'Доступ по LAN', + 'agents.mcpClientConfig': 'Конфиг клиента', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/th.ts b/src/i18n/locales/th.ts index 4f23e4b5..327841d6 100644 --- a/src/i18n/locales/th.ts +++ b/src/i18n/locales/th.ts @@ -44,6 +44,7 @@ const th: TranslationKeys = { 'topbar.open': 'เปิด', 'topbar.save': 'บันทึก', 'topbar.importFigma': 'นำเข้า Figma', + 'topbar.codePanel': 'โค้ด', 'topbar.lightMode': 'โหมดสว่าง', 'topbar.darkMode': 'โหมดมืด', 'topbar.fullscreen': 'เต็มหน้าจอ', @@ -54,6 +55,11 @@ const th: TranslationKeys = { 'topbar.connected': 'เชื่อมต่อแล้ว', 'topbar.agentStatus': '{{agents}} เอเจนต์{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'ออกแบบ', + 'rightPanel.code': 'โค้ด', + 'rightPanel.noSelection': 'เลือกองค์ประกอบ', + // ── Pages ── 'pages.title': 'หน้า', 'pages.addPage': 'เพิ่มหน้า', @@ -105,6 +111,9 @@ const th: TranslationKeys = { 'layerMenu.createComponent': 'สร้างคอมโพเนนต์', 'layerMenu.detachComponent': 'แยกคอมโพเนนต์', 'layerMenu.detachInstance': 'แยกอินสแตนซ์', + 'layerMenu.booleanUnion': 'รวม', + 'layerMenu.booleanSubtract': 'ลบ', + 'layerMenu.booleanIntersect': 'ตัดกัน', 'layerMenu.toggleLock': 'สลับล็อก', 'layerMenu.toggleVisibility': 'สลับการมองเห็น', @@ -264,11 +273,18 @@ const th: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'คัดลอกไปยังคลิปบอร์ด', + 'code.copied': 'คัดลอกแล้ว!', + 'code.download': 'ดาวน์โหลดไฟล์โค้ด', 'code.closeCodePanel': 'ปิดแผงโค้ด', 'code.genCssVars': 'กำลังสร้าง CSS Variables สำหรับเอกสารทั้งหมด', 'code.genSelected': 'กำลังสร้างโค้ดสำหรับ {{count}} องค์ประกอบที่เลือก', 'code.genDocument': 'กำลังสร้างโค้ดสำหรับเอกสารทั้งหมด', + 'code.aiEnhance': 'ปรับปรุงด้วย AI', + 'code.cancelEnhance': 'ยกเลิกการปรับปรุง', + 'code.resetEnhance': 'กลับเป็นต้นฉบับ', + 'code.enhancing': 'AI กำลังปรับปรุงโค้ด...', + 'code.enhanced': 'ปรับปรุงแล้วโดย AI', // ── Save Dialog ── 'save.saveAs': 'บันทึกเป็น', @@ -303,6 +319,7 @@ const th: TranslationKeys = { 'agents.mcpServerRunning': 'กำลังทำงาน', 'agents.mcpServerStopped': 'หยุดแล้ว', 'agents.mcpLanAccess': 'เข้าถึง LAN', + 'agents.mcpClientConfig': 'การตั้งค่าไคลเอนต์', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 44e392f2..6fe8d394 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -44,6 +44,7 @@ const tr: TranslationKeys = { 'topbar.open': 'Aç', 'topbar.save': 'Kaydet', 'topbar.importFigma': 'Figma İçe Aktar', + 'topbar.codePanel': 'Kod', 'topbar.lightMode': 'Açık mod', 'topbar.darkMode': 'Koyu mod', 'topbar.fullscreen': 'Tam ekran', @@ -54,6 +55,11 @@ const tr: TranslationKeys = { 'topbar.connected': 'bağlı', 'topbar.agentStatus': '{{agents}} ajan{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Tasarım', + 'rightPanel.code': 'Kod', + 'rightPanel.noSelection': 'Bir öğe seçin', + // ── Pages ── 'pages.title': 'Sayfalar', 'pages.addPage': 'Sayfa ekle', @@ -105,6 +111,9 @@ const tr: TranslationKeys = { 'layerMenu.createComponent': 'Bileşen Oluştur', 'layerMenu.detachComponent': 'Bileşeni Ayır', 'layerMenu.detachInstance': 'Örneği Ayır', + 'layerMenu.booleanUnion': 'Birleştir', + 'layerMenu.booleanSubtract': 'Çıkar', + 'layerMenu.booleanIntersect': 'Kesiştir', 'layerMenu.toggleLock': 'Kilidi Aç/Kapat', 'layerMenu.toggleVisibility': 'Görünürlüğü Aç/Kapat', @@ -264,11 +273,18 @@ const tr: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Panoya kopyala', + 'code.copied': 'Kopyalandı!', + 'code.download': 'Kod dosyasını indir', 'code.closeCodePanel': 'Kod panelini kapat', 'code.genCssVars': 'Tüm belge için CSS değişkenleri oluşturuluyor', 'code.genSelected': '{{count}} seçili öge için kod oluşturuluyor', 'code.genDocument': 'Tüm belge için kod oluşturuluyor', + 'code.aiEnhance': 'AI ile geliştir', + 'code.cancelEnhance': 'Geliştirmeyi iptal et', + 'code.resetEnhance': 'Orijinale sıfırla', + 'code.enhancing': 'AI kodu geliştiriyor...', + 'code.enhanced': 'AI tarafından geliştirildi', // ── Save Dialog ── 'save.saveAs': 'Farklı Kaydet', @@ -303,6 +319,7 @@ const tr: TranslationKeys = { 'agents.mcpServerRunning': 'Çalışıyor', 'agents.mcpServerStopped': 'Durduruldu', 'agents.mcpLanAccess': 'LAN Erişimi', + 'agents.mcpClientConfig': 'İstemci yapılandırması', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/vi.ts b/src/i18n/locales/vi.ts index 97abbdeb..6f5078b3 100644 --- a/src/i18n/locales/vi.ts +++ b/src/i18n/locales/vi.ts @@ -44,6 +44,7 @@ const vi: TranslationKeys = { 'topbar.open': 'Mở', 'topbar.save': 'Lưu', 'topbar.importFigma': 'Nhập từ Figma', + 'topbar.codePanel': 'Mã', 'topbar.lightMode': 'Chế độ sáng', 'topbar.darkMode': 'Chế độ tối', 'topbar.fullscreen': 'Toàn màn hình', @@ -54,6 +55,11 @@ const vi: TranslationKeys = { 'topbar.connected': 'đã kết nối', 'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP', + // ── Right Panel ── + 'rightPanel.design': 'Thiết kế', + 'rightPanel.code': 'Mã', + 'rightPanel.noSelection': 'Chọn một phần tử', + // ── Pages ── 'pages.title': 'Trang', 'pages.addPage': 'Thêm trang', @@ -105,6 +111,9 @@ const vi: TranslationKeys = { 'layerMenu.createComponent': 'Tạo thành phần', 'layerMenu.detachComponent': 'Tách thành phần', 'layerMenu.detachInstance': 'Tách bản thể', + 'layerMenu.booleanUnion': 'Hợp nhất', + 'layerMenu.booleanSubtract': 'Trừ', + 'layerMenu.booleanIntersect': 'Giao nhau', 'layerMenu.toggleLock': 'Bật/Tắt khoá', 'layerMenu.toggleVisibility': 'Bật/Tắt hiển thị', @@ -264,11 +273,18 @@ const vi: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': 'Sao chép vào bộ nhớ tạm', + 'code.copied': 'Đã sao chép!', + 'code.download': 'Tải xuống tệp mã', 'code.closeCodePanel': 'Đóng bảng mã', 'code.genCssVars': 'Đang tạo CSS variables cho toàn bộ tài liệu', 'code.genSelected': 'Đang tạo mã cho {{count}} phần tử đã chọn', 'code.genDocument': 'Đang tạo mã cho toàn bộ tài liệu', + 'code.aiEnhance': 'Cải thiện bằng AI', + 'code.cancelEnhance': 'Hủy cải thiện', + 'code.resetEnhance': 'Khôi phục gốc', + 'code.enhancing': 'AI đang cải thiện mã...', + 'code.enhanced': 'Đã cải thiện bởi AI', // ── Save Dialog ── 'save.saveAs': 'Lưu thành', @@ -303,6 +319,7 @@ const vi: TranslationKeys = { 'agents.mcpServerRunning': 'Đang chạy', 'agents.mcpServerStopped': 'Đã dừng', 'agents.mcpLanAccess': 'Truy cập LAN', + 'agents.mcpClientConfig': 'Cấu hình client', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts index d9b60fbd..a2a43b10 100644 --- a/src/i18n/locales/zh-tw.ts +++ b/src/i18n/locales/zh-tw.ts @@ -44,6 +44,7 @@ const zhTW: TranslationKeys = { 'topbar.open': '開啟', 'topbar.save': '儲存', 'topbar.importFigma': '匯入 Figma', + 'topbar.codePanel': '程式碼', 'topbar.lightMode': '淺色模式', 'topbar.darkMode': '深色模式', 'topbar.fullscreen': '全螢幕', @@ -54,6 +55,11 @@ const zhTW: TranslationKeys = { 'topbar.connected': '已連線', 'topbar.agentStatus': '{{agents}} 個 Agent{{agentSuffix}} · {{mcp}} 個 MCP', + // ── Right Panel ── + 'rightPanel.design': '設計', + 'rightPanel.code': '程式碼', + 'rightPanel.noSelection': '選擇一個元素', + // ── Pages ── 'pages.title': '頁面', 'pages.addPage': '新增頁面', @@ -102,6 +108,9 @@ const zhTW: TranslationKeys = { 'layerMenu.createComponent': '建立元件', 'layerMenu.detachComponent': '分離元件', 'layerMenu.detachInstance': '分離實例', + 'layerMenu.booleanUnion': '聯合', + 'layerMenu.booleanSubtract': '減去', + 'layerMenu.booleanIntersect': '交集', 'layerMenu.toggleLock': '切換鎖定', 'layerMenu.toggleVisibility': '切換可見性', @@ -258,10 +267,17 @@ const zhTW: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': '複製到剪貼簿', + 'code.copied': '已複製!', + 'code.download': '下載程式碼檔案', 'code.closeCodePanel': '關閉程式碼面板', 'code.genCssVars': '正在為整份文件產生 CSS 變數', 'code.genSelected': '正在為 {{count}} 個選取元素產生程式碼', 'code.genDocument': '正在為整份文件產生程式碼', + 'code.aiEnhance': 'AI 優化', + 'code.cancelEnhance': '取消優化', + 'code.resetEnhance': '恢復原始程式碼', + 'code.enhancing': 'AI 正在優化程式碼...', + 'code.enhanced': '已由 AI 優化', // ── Save Dialog ── 'save.saveAs': '另存新檔', @@ -295,6 +311,7 @@ const zhTW: TranslationKeys = { 'agents.mcpServerRunning': '執行中', 'agents.mcpServerStopped': '已停止', 'agents.mcpLanAccess': '區域網路存取', + 'agents.mcpClientConfig': '客戶端配置', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index e9405ff0..c54dabc3 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -44,6 +44,7 @@ const zh: TranslationKeys = { 'topbar.open': '打开', 'topbar.save': '保存', 'topbar.importFigma': '导入 Figma', + 'topbar.codePanel': '代码', 'topbar.lightMode': '浅色模式', 'topbar.darkMode': '深色模式', 'topbar.fullscreen': '全屏', @@ -54,6 +55,11 @@ const zh: TranslationKeys = { 'topbar.connected': '已连接', 'topbar.agentStatus': '{{agents}} 个 Agent{{agentSuffix}} · {{mcp}} 个 MCP', + // ── Right Panel ── + 'rightPanel.design': '设计', + 'rightPanel.code': '代码', + 'rightPanel.noSelection': '选择一个元素', + // ── Pages ── 'pages.title': '页面', 'pages.addPage': '添加页面', @@ -102,6 +108,9 @@ const zh: TranslationKeys = { 'layerMenu.createComponent': '创建组件', 'layerMenu.detachComponent': '分离组件', 'layerMenu.detachInstance': '分离实例', + 'layerMenu.booleanUnion': '联合', + 'layerMenu.booleanSubtract': '减去', + 'layerMenu.booleanIntersect': '交集', 'layerMenu.toggleLock': '切换锁定', 'layerMenu.toggleVisibility': '切换可见性', @@ -258,10 +267,17 @@ const zh: TranslationKeys = { 'code.htmlCss': 'HTML + CSS', 'code.cssVariables': 'CSS Variables', 'code.copyClipboard': '复制到剪贴板', + 'code.copied': '已复制!', + 'code.download': '下载代码文件', 'code.closeCodePanel': '关闭代码面板', 'code.genCssVars': '正在为整个文档生成 CSS 变量', 'code.genSelected': '正在为 {{count}} 个选中元素生成代码', 'code.genDocument': '正在为整个文档生成代码', + 'code.aiEnhance': 'AI 优化', + 'code.cancelEnhance': '取消优化', + 'code.resetEnhance': '恢复原始代码', + 'code.enhancing': 'AI 正在优化代码...', + 'code.enhanced': '已由 AI 优化', // ── Save Dialog ── 'save.saveAs': '另存为', @@ -295,6 +311,7 @@ const zh: TranslationKeys = { 'agents.mcpServerRunning': '运行中', 'agents.mcpServerStopped': '已停止', 'agents.mcpLanAccess': '局域网访问', + 'agents.mcpClientConfig': '客户端配置', 'agents.stdio': 'stdio', 'agents.http': 'http', 'agents.stdioHttp': 'stdio + http', diff --git a/src/mcp/document-manager.ts b/src/mcp/document-manager.ts index 2b330c13..ae4b67ba 100644 --- a/src/mcp/document-manager.ts +++ b/src/mcp/document-manager.ts @@ -10,9 +10,9 @@ const cache = new Map() /** Special path indicating the MCP should operate on the live Electron canvas. */ export const LIVE_CANVAS_PATH = 'live://canvas' -/** Resolve filePath for MCP tools — passes through live://canvas, resolves file paths normally. */ -export function resolveDocPath(filePath: string): string { - if (filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH +/** Resolve filePath for MCP tools — defaults to live canvas when omitted. */ +export function resolveDocPath(filePath?: string): string { + if (!filePath || filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH return resolve(filePath) } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8584c1f8..6f7095aa 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -60,7 +60,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, patterns: { type: 'array', description: 'Search patterns to match nodes', @@ -79,7 +79,7 @@ const TOOL_DEFINITIONS = [ searchDepth: { type: 'number', description: 'How deep to search for matching nodes (default unlimited)' }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath'], + required: [], }, }, { @@ -89,7 +89,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, parent: { type: ['string', 'null'] as any, description: 'Parent node ID, or null for root level', @@ -110,7 +110,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'parent', 'data'], + required: ['parent', 'data'], }, }, { @@ -120,7 +120,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, nodeId: { type: 'string', description: 'ID of the node to update' }, data: { type: 'object', @@ -136,7 +136,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'nodeId', 'data'], + required: ['nodeId', 'data'], }, }, { @@ -145,11 +145,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, nodeId: { type: 'string', description: 'ID of the node to delete' }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'nodeId'], + required: ['nodeId'], }, }, { @@ -159,7 +159,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, nodeId: { type: 'string', description: 'ID of the node to move' }, parent: { type: ['string', 'null'] as any, @@ -171,7 +171,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'nodeId', 'parent'], + required: ['nodeId', 'parent'], }, }, { @@ -181,7 +181,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, sourceId: { type: 'string', description: 'ID of the node to copy' }, parent: { type: ['string', 'null'] as any, @@ -193,7 +193,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'sourceId', 'parent'], + required: ['sourceId', 'parent'], }, }, { @@ -203,7 +203,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, nodeId: { type: 'string', description: 'ID of the node to replace' }, data: { type: 'object', @@ -219,7 +219,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'nodeId', 'data'], + required: ['nodeId', 'data'], }, }, { @@ -229,7 +229,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, svgPath: { type: 'string', description: 'Absolute path to a local .svg file' }, parent: { type: ['string', 'null'] as any, @@ -249,7 +249,7 @@ const TOOL_DEFINITIONS = [ }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'svgPath'], + required: ['svgPath'], }, }, { @@ -258,9 +258,9 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, }, - required: ['filePath'], + required: [], }, }, { @@ -269,11 +269,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, variables: { type: 'object', description: 'Variables to set (name → { type, value })' }, replace: { type: 'boolean', description: 'Replace all variables instead of merging (default false)' }, }, - required: ['filePath', 'variables'], + required: ['variables'], }, }, { @@ -283,7 +283,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, themes: { type: 'object', description: @@ -294,7 +294,7 @@ const TOOL_DEFINITIONS = [ description: 'Replace all themes instead of merging (default false)', }, }, - required: ['filePath', 'themes'], + required: ['themes'], }, }, { @@ -303,12 +303,12 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, parentId: { type: 'string', description: 'Only return layout under this parent node' }, maxDepth: { type: 'number', description: 'Max depth to traverse (default 1)' }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath'], + required: [], }, }, { @@ -317,7 +317,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, width: { type: 'number', description: 'Required width of empty space' }, height: { type: 'number', description: 'Required height of empty space' }, padding: { type: 'number', description: 'Minimum padding from other elements (default 50)' }, @@ -325,7 +325,7 @@ const TOOL_DEFINITIONS = [ nodeId: { type: 'string', description: 'Search relative to this node (default: entire canvas)' }, pageId: { type: 'string', description: 'Target page ID (defaults to first page)' }, }, - required: ['filePath', 'width', 'height', 'direction'], + required: ['width', 'height', 'direction'], }, }, { @@ -334,11 +334,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file to extract themes from' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, presetPath: { type: 'string', description: 'Absolute path for the output .optheme file' }, name: { type: 'string', description: 'Display name for the preset (defaults to file name)' }, }, - required: ['filePath', 'presetPath'], + required: ['presetPath'], }, }, { @@ -347,10 +347,10 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file to update' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, presetPath: { type: 'string', description: 'Absolute path to the .optheme file to load' }, }, - required: ['filePath', 'presetPath'], + required: ['presetPath'], }, }, { @@ -371,7 +371,7 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, name: { type: 'string', description: 'Page name (default: "Page N")' }, children: { type: 'array', @@ -380,7 +380,7 @@ const TOOL_DEFINITIONS = [ items: { type: 'object' }, }, }, - required: ['filePath'], + required: [], }, }, { @@ -389,10 +389,10 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, pageId: { type: 'string', description: 'ID of the page to remove' }, }, - required: ['filePath', 'pageId'], + required: ['pageId'], }, }, { @@ -401,11 +401,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, pageId: { type: 'string', description: 'ID of the page to rename' }, name: { type: 'string', description: 'New page name' }, }, - required: ['filePath', 'pageId', 'name'], + required: ['pageId', 'name'], }, }, { @@ -414,11 +414,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, pageId: { type: 'string', description: 'ID of the page to move' }, index: { type: 'number', description: 'New zero-based index for the page' }, }, - required: ['filePath', 'pageId', 'index'], + required: ['pageId', 'index'], }, }, { @@ -428,11 +428,11 @@ const TOOL_DEFINITIONS = [ inputSchema: { type: 'object' as const, properties: { - filePath: { type: 'string', description: 'Absolute path to the .op file' }, + filePath: { type: 'string', description: 'Path to .op file, or omit to use the live canvas (default)' }, pageId: { type: 'string', description: 'ID of the page to duplicate' }, name: { type: 'string', description: 'Name for the duplicated page (default: "original copy")' }, }, - required: ['filePath', 'pageId'], + required: ['pageId'], }, }, ] @@ -512,12 +512,9 @@ function registerTools(server: Server): void { // --- HTTP server helper --- -function startHttpServer(server: Server, port: number): void { - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }) - - server.connect(transport) +function startHttpServer(port: number): void { + // Per-session transport map: each client gets its own Server + Transport + const sessions = new Map() const httpServer = createServer(async (req, res) => { res.setHeader('Access-Control-Allow-Origin', '*') @@ -531,19 +528,62 @@ function startHttpServer(server: Server, port: number): void { return } - if (req.url === '/mcp') { + if (req.url !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' })) + return + } + + const sessionId = req.headers['mcp-session-id'] as string | undefined + + // Route to existing session + if (sessionId && sessions.has(sessionId)) { + const session = sessions.get(sessionId)! if (req.method === 'POST') { const chunks: Buffer[] = [] for await (const chunk of req) chunks.push(chunk as Buffer) const body = JSON.parse(Buffer.concat(chunks).toString()) - await transport.handleRequest(req, res, body) + await session.transport.handleRequest(req, res, body) } else { - await transport.handleRequest(req, res) + await session.transport.handleRequest(req, res) } - } else { - res.writeHead(404, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' })) + return } + + // New session — only POST (initialize) is valid without session ID + if (req.method === 'POST') { + const mcpServer = new Server( + { name: pkg.name, version: pkg.version }, + { capabilities: { tools: {} } }, + ) + registerTools(mcpServer) + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + sessions.set(sid, { transport, server: mcpServer }) + }, + onsessionclosed: (sid: string) => { + sessions.delete(sid) + }, + }) + + transport.onclose = () => { + if (transport.sessionId) sessions.delete(transport.sessionId) + } + + await mcpServer.connect(transport) + + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const body = JSON.parse(Buffer.concat(chunks).toString()) + await transport.handleRequest(req, res, body) + return + } + + // Invalid: GET/DELETE without valid session ID + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Invalid or missing session ID' }, id: null })) }) httpServer.listen(port, '0.0.0.0', () => { @@ -569,7 +609,7 @@ async function main() { const { stdio, http, port } = parseArgs() if (stdio && http) { - // Both: two Server instances sharing the same tool handlers + // Both: stdio server + HTTP server (per-session) const stdioServer = new Server( { name: pkg.name, version: pkg.version }, { capabilities: { tools: {} } }, @@ -577,19 +617,9 @@ async function main() { registerTools(stdioServer) await stdioServer.connect(new StdioServerTransport()) - const httpServer = new Server( - { name: pkg.name, version: pkg.version }, - { capabilities: { tools: {} } }, - ) - registerTools(httpServer) - startHttpServer(httpServer, port) + startHttpServer(port) } else if (http) { - const server = new Server( - { name: pkg.name, version: pkg.version }, - { capabilities: { tools: {} } }, - ) - registerTools(server) - startHttpServer(server, port) + startHttpServer(port) } else { const server = new Server( { name: pkg.name, version: pkg.version }, diff --git a/src/mcp/tools/batch-design.ts b/src/mcp/tools/batch-design.ts index 66a1335f..5bab996e 100644 --- a/src/mcp/tools/batch-design.ts +++ b/src/mcp/tools/batch-design.ts @@ -25,7 +25,7 @@ import { import type { PenDocument, PenNode } from '../../types/pen' export interface BatchDesignParams { - filePath: string + filePath?: string operations: string postProcess?: boolean canvasWidth?: number diff --git a/src/mcp/tools/batch-get.ts b/src/mcp/tools/batch-get.ts index 0b7fb7fb..48778fcf 100644 --- a/src/mcp/tools/batch-get.ts +++ b/src/mcp/tools/batch-get.ts @@ -14,7 +14,7 @@ export interface SearchPattern { } export interface BatchGetParams { - filePath: string + filePath?: string patterns?: SearchPattern[] nodeIds?: string[] parentId?: string diff --git a/src/mcp/tools/design-prompt.ts b/src/mcp/tools/design-prompt.ts index 79c0be09..1c49ec3d 100644 --- a/src/mcp/tools/design-prompt.ts +++ b/src/mcp/tools/design-prompt.ts @@ -22,6 +22,55 @@ ${ADAPTIVE_STYLE_POLICY} ${DESIGN_EXAMPLES} +DESIGN TYPE DETECTION: +Classify by the design's PURPOSE to choose the correct root frame size — reason about intent, do not keyword-match: +- Multi-section page (marketing, promotional, informational content designed to be scrolled) → Desktop: width=1200, height=0 (auto-expands), layout="vertical" +- Single-task screen (functional UI focused on one user task: authentication, forms, settings, profiles, modals, onboarding, etc.) → Mobile: width=375, height=812 (FIXED) +- Data-rich workspace (dashboards, admin panels, analytics) → Desktop: width=1200, height=0 +- CRITICAL: Single-task screens MUST be 375×812. NEVER use 1200 width for focused app screens. +- Multi-section page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, CTA 200-300px, footer 200-300px. +- Single-task screen height hints: status bar 44px, header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px. + +SEMANTIC ROLES (context-aware defaults): +Add "role" to nodes for automatic smart defaults. System fills unset props based on role. Your explicit props always override. +Available roles: + Layout: section, row, column, centered-content, form-group, divider, spacer + Navigation: navbar, nav-links, nav-link + Interactive: button, icon-button, badge, tag, pill, input, form-input, search-bar + Display: card, stat-card, pricing-card, feature-card, image-card + Media: phone-mockup, screenshot-frame, avatar, icon + Typography: heading, subheading, body-text, caption, label + Content: hero, feature-grid, testimonial, cta-section, footer, stats-section + Table: table, table-row, table-header, table-cell +Key role defaults: + section → width:fill_container, height:fit_content, gap:24, padding:[60,80] (desktop)/[40,16] (mobile) + navbar → height:72 (desktop)/56 (mobile), layout:horizontal, justifyContent:space_between, alignItems:center + hero → layout:vertical, padding:[80,80] (desktop)/[40,16] (mobile), gap:24, alignItems:center + button → padding:[12,24], height:44, cornerRadius:8, layout:horizontal, alignItems:center + button (in navbar) → padding:[8,16], height:36 + button (in form-group) → width:fill_container, height:48, padding:[12,24] + icon-button → 44×44, layout:horizontal, justifyContent:center, alignItems:center, cornerRadius:8 + badge/pill → layout:horizontal, padding:[6,12], cornerRadius:999 + input → height:48, layout:horizontal, padding:[12,16] + form-input → same as input + width:fill_container + search-bar → height:44, cornerRadius:22 + card → gap:12, cornerRadius:12, clipContent:true + card (in horizontal layout) → width:fill_container, height:fill_container + feature-card (in horizontal) → width:fill_container, height:fill_container + phone-mockup → width:280, height:560, cornerRadius:32, layout:none + avatar → circular (cornerRadius=width/2), clipContent:true + heading → lineHeight:1.2, letterSpacing:-0.5 + body-text → lineHeight:1.5, textGrowth:fixed-width, width:fill_container + caption → lineHeight:1.3, textGrowth:auto + label → lineHeight:1.2, textGrowth:auto, textAlignVertical:middle + divider → width:fill_container, height:1 (or width:1 for vertical) + spacer → width:fill_container, height:40 + feature-grid → layout:horizontal, gap:24, alignItems:start + table → layout:vertical, gap:0, clipContent:true + table-row → layout:horizontal, padding:[12,16], alignItems:center + table-cell → width:fill_container +Any string is valid as a role — unknown roles pass through unchanged. + LAYOUT ENGINE (flexbox-based): - Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems - NEVER set x/y on children inside layout containers — the engine positions them automatically @@ -39,42 +88,71 @@ LAYOUT ENGINE (flexbox-based): - ALL nodes must be descendants of the root frame — no floating/orphan elements - WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one uses "fill_container", ALL siblings must too. - NEVER use "fill_container" on children of a "fit_content" parent — circular dependency. -- TEXT IN LAYOUTS: in vertical layouts, body text → textGrowth="fixed-width" + width="fill_container". In horizontal rows, labels → textGrowth="auto" + width="fit_content". NEVER use fixed pixel width on text inside a layout. -- TEXT HEIGHT: NEVER set explicit pixel height on text nodes. OMIT height — the engine auto-calculates. -- CJK BUTTONS/BADGES: each CJK char ≈ fontSize wide. Ensure container width ≥ (charCount × fontSize) + padding. +- Section root: width="fill_container", height="fit_content", layout="vertical". Never fixed pixel height on section root. +- Two-column: horizontal frame, two child frames each "fill_container" width. +- Centered content: frame alignItems="center", content frame with fixed width (e.g. 1080). +- FORMS: ALL inputs AND primary button MUST use width="fill_container". Vertical layout, gap=16-20. ONE primary action button only. + Social login buttons: horizontal frame width="fill_container", each button width="fit_content". +- Keep hierarchy shallow: no pointless "Inner" wrappers. Only use wrappers with a visual purpose (fill, padding, border). + +TEXT RULES: +- Body/description text in vertical layout: width="fill_container" + textGrowth="fixed-width". This wraps text and auto-sizes height. +- Short labels in horizontal rows: width="fit_content" (or omit) + textGrowth="auto" (or omit). Prevents squeezing siblings. +- NEVER fixed pixel width on text inside layout frames — causes overflow. Only allowed in layout="none" parent. +- Text >15 chars MUST have textGrowth="fixed-width" — without it text won't wrap. +- NEVER set explicit pixel height on text nodes. OMIT height — the engine auto-calculates. +- Typography scale: Display 40-56px → Heading 28-36px → Subheading 20-24px → Body 16-18px → Caption 13-14px. + lineHeight: headings 1.1-1.2, body 1.4-1.6. letterSpacing: -0.5 for headlines, 0.5-2 for uppercase. + +CJK TYPOGRAPHY: +- CJK font selection: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". + NEVER use "Space Grotesk" or "Manrope" for CJK content — they have no CJK glyphs. +- CJK lineHeight: headings 1.3-1.4 (NOT 1.1-1.2 like Latin), body 1.6-1.8 (NOT 1.4-1.6 like Latin). +- CJK letterSpacing: 0, NEVER negative. Negative letterSpacing causes CJK character overlap. +- CJK buttons/badges: each CJK char ≈ fontSize wide. Ensure container width ≥ (charCount × fontSize) + padding. COPYWRITING: - Headlines: 2-6 words. Subtitles: 1 sentence ≤15 words. Buttons: 1-3 words. Card text: ≤2 sentences. - NEVER generate placeholder paragraphs with 3+ sentences. Distill to essence. DESIGN GUIDELINES: -- Mobile: root frame 375x812 at x:0,y:0. Web: 1200x800 (single screen) or 1200x3000-5000 (landing page). - Use unique descriptive IDs. All elements INSIDE root frame as children. - Max 3-4 levels of nesting. Consistent centered content container (~1040-1160px) for web. - Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24]. Icon+text: layout="horizontal", gap=8, alignItems="center". -- Inputs: height 44px, light bg, subtle border, width="fill_container" in forms. -- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use height="fill_container". -- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix). Size 16-24px. System auto-resolves names to SVG paths. -- Never use emoji as icons. Never use ellipse for decorative shapes. -- Phone mockup: ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. +- Icon-only buttons: square ≥44×44, justifyContent/alignItems="center", path icon 20-24px. +- Inputs: height 48px, light bg, subtle border, width="fill_container" in forms. + Semantic affordance icons: search→leading SearchIcon, password→trailing EyeIcon, email→leading MailIcon. +- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use width="fill_container" + height="fill_container". +- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix, e.g. "SearchIcon", "MenuIcon"). Size 16-24px. System auto-resolves names to SVG paths. +- Never use emoji as icons. Never use ellipse for decorative shapes — use frame/rectangle with cornerRadius. +- Phone mockup: ONE "frame" node with role="phone-mockup". No ellipse for mockups. At most ONE centered text child inside. +- Hero + phone (desktop): two-column horizontal layout (left text, right phone). Not stacked unless mobile. +- Landing pages: hero 40-56px headline, alternating section backgrounds, nav with space_between. +- App screens: focus on core function, inputs width="fill_container", consistent 48-56px height, 16-24px gap. - Default to light neutral styling unless user asks for dark. + Dark theme only when user explicitly mentions: dark/cyber/terminal/neon/夜间/暗黑/gaming/noir. DESIGN VARIABLES: - When document has variables, use "$variableName" references instead of hardcoded values. - Color variables: [{ "type": "solid", "color": "$primary" }] - Number variables: "gap": "$spacing-md" +- Variables can have per-theme values. Use $name syntax — the engine resolves to concrete values for rendering. EMPTY FRAME AUTO-REPLACEMENT: - When inserting a root-level frame via I(null, {...}), if an empty root frame (no children) already exists on the canvas, it is automatically replaced — no need to delete or move into it manually. - The new frame inherits the position (x/y) of the replaced empty frame, so find_empty_space is unnecessary when an empty root frame exists. - Always use I(null, {...}) for root-level designs — the tool handles reuse of empty frames automatically. -POST-PROCESSING (automatic): -- batch_design with postProcess=true automatically applies after insertion: - - Semantic role defaults (button padding, card corners, input styling, etc.) - - Icon name → SVG path resolution - - Emoji removal - - Layout child position sanitization - - Unique ID enforcement +POST-PROCESSING (automatic with postProcess=true): +- Semantic role defaults: fills unset props based on role (see SEMANTIC ROLES above). Context-aware — e.g. button defaults differ in navbar vs form. +- Icon name → SVG path auto-resolution: set icon "name" field, system resolves to SVG "d" path. +- Card row equalization: horizontal layout with 2+ cards auto-equalizes to fill_container width+height. +- Horizontal overflow fix: auto-reduces gap or expands parent when children exceed width. +- Form input consistency: if any input uses fill_container, all sibling inputs get normalized. +- Text height estimation: auto-calculates optimal height based on fontSize, lineHeight, and content width. +- Frame height expansion: auto-expands frames when content exceeds fixed height. +- clipContent auto-addition: frames with cornerRadius + image children get clipContent:true. +- Emoji removal and layout child position sanitization. +- Unique ID enforcement. Always set postProcess=true when generating designs for best visual quality.` } diff --git a/src/mcp/tools/find-empty-space.ts b/src/mcp/tools/find-empty-space.ts index 88a52d00..dd83223d 100644 --- a/src/mcp/tools/find-empty-space.ts +++ b/src/mcp/tools/find-empty-space.ts @@ -3,7 +3,7 @@ import { getNodeBounds, findNodeInTree, getDocChildren } from '../utils/node-ope import type { PenNode } from '../../types/pen' export interface FindEmptySpaceParams { - filePath: string + filePath?: string width: number height: number padding?: number diff --git a/src/mcp/tools/import-svg.ts b/src/mcp/tools/import-svg.ts index ce5435a7..fbf3cde1 100644 --- a/src/mcp/tools/import-svg.ts +++ b/src/mcp/tools/import-svg.ts @@ -11,7 +11,7 @@ import { parseSvgToNodesServer } from '../utils/svg-node-parser' import { postProcessNode } from './node-crud' export interface ImportSvgParams { - filePath: string + filePath?: string svgPath: string parent?: string | null maxDim?: number diff --git a/src/mcp/tools/node-crud.ts b/src/mcp/tools/node-crud.ts index 8f5857f7..3c162891 100644 --- a/src/mcp/tools/node-crud.ts +++ b/src/mcp/tools/node-crud.ts @@ -73,7 +73,7 @@ function isEmptyFrame(node: PenNode): boolean { // --------------------------------------------------------------------------- export interface InsertNodeParams { - filePath: string + filePath?: string parent: string | null data: Record postProcess?: boolean @@ -132,7 +132,7 @@ export async function handleInsertNode( // --------------------------------------------------------------------------- export interface UpdateNodeParams { - filePath: string + filePath?: string nodeId: string data: Record postProcess?: boolean @@ -164,7 +164,7 @@ export async function handleUpdateNode( // --------------------------------------------------------------------------- export interface DeleteNodeParams { - filePath: string + filePath?: string nodeId: string pageId?: string } @@ -190,7 +190,7 @@ export async function handleDeleteNode( // --------------------------------------------------------------------------- export interface MoveNodeParams { - filePath: string + filePath?: string nodeId: string parent: string | null index?: number @@ -221,7 +221,7 @@ export async function handleMoveNode( // --------------------------------------------------------------------------- export interface CopyNodeParams { - filePath: string + filePath?: string sourceId: string parent: string | null overrides?: Record @@ -260,7 +260,7 @@ export async function handleCopyNode( // --------------------------------------------------------------------------- export interface ReplaceNodeParams { - filePath: string + filePath?: string nodeId: string data: Record postProcess?: boolean diff --git a/src/mcp/tools/pages.ts b/src/mcp/tools/pages.ts index dff7df9d..f6ee6108 100644 --- a/src/mcp/tools/pages.ts +++ b/src/mcp/tools/pages.ts @@ -8,7 +8,7 @@ import type { PenPage, PenNode } from '../../types/pen' // --------------------------------------------------------------------------- export interface AddPageParams { - filePath: string + filePath?: string name?: string children?: Record[] } @@ -62,7 +62,7 @@ export async function handleAddPage( // --------------------------------------------------------------------------- export interface RemovePageParams { - filePath: string + filePath?: string pageId: string } @@ -91,7 +91,7 @@ export async function handleRemovePage( // --------------------------------------------------------------------------- export interface RenamePageParams { - filePath: string + filePath?: string pageId: string name: string } @@ -118,7 +118,7 @@ export async function handleRenamePage( // --------------------------------------------------------------------------- export interface ReorderPageParams { - filePath: string + filePath?: string pageId: string index: number } @@ -147,7 +147,7 @@ export async function handleReorderPage( // --------------------------------------------------------------------------- export interface DuplicatePageParams { - filePath: string + filePath?: string pageId: string name?: string } diff --git a/src/mcp/tools/snapshot-layout.ts b/src/mcp/tools/snapshot-layout.ts index 48c26e32..94f109d8 100644 --- a/src/mcp/tools/snapshot-layout.ts +++ b/src/mcp/tools/snapshot-layout.ts @@ -2,7 +2,7 @@ import { openDocument, resolveDocPath } from '../document-manager' import { computeLayoutTree, getDocChildren, type LayoutEntry } from '../utils/node-operations' export interface SnapshotLayoutParams { - filePath: string + filePath?: string parentId?: string maxDepth?: number pageId?: string diff --git a/src/mcp/tools/theme-presets.ts b/src/mcp/tools/theme-presets.ts index 682267c3..f734b4aa 100644 --- a/src/mcp/tools/theme-presets.ts +++ b/src/mcp/tools/theme-presets.ts @@ -9,7 +9,7 @@ import type { VariableDefinition } from '../../types/variables' // --------------------------------------------------------------------------- export interface SaveThemePresetParams { - filePath: string + filePath?: string presetPath: string name?: string } @@ -38,7 +38,7 @@ export async function handleSaveThemePreset( // --------------------------------------------------------------------------- export interface LoadThemePresetParams { - filePath: string + filePath?: string presetPath: string } diff --git a/src/mcp/tools/variables.ts b/src/mcp/tools/variables.ts index 50fb6a4f..fa2db5b3 100644 --- a/src/mcp/tools/variables.ts +++ b/src/mcp/tools/variables.ts @@ -2,11 +2,11 @@ import { openDocument, saveDocument, resolveDocPath } from '../document-manager' import type { VariableDefinition } from '../../types/variables' export interface GetVariablesParams { - filePath: string + filePath?: string } export interface SetVariablesParams { - filePath: string + filePath?: string variables: Record replace?: boolean } @@ -43,7 +43,7 @@ export async function handleSetVariables( // --------------------------------------------------------------------------- export interface SetThemesParams { - filePath: string + filePath?: string themes: Record replace?: boolean } diff --git a/src/services/ai/ai-runtime-config.ts b/src/services/ai/ai-runtime-config.ts index d365c48a..59df9196 100644 --- a/src/services/ai/ai-runtime-config.ts +++ b/src/services/ai/ai-runtime-config.ts @@ -105,7 +105,9 @@ export const DESIGN_STREAM_TIMEOUTS = { effort: DEFAULT_THINKING_EFFORT, } as const -export const VALIDATION_TIMEOUT_MS = 30_000 +export const VALIDATION_TIMEOUT_MS = 180_000 +export const MAX_VALIDATION_ROUNDS = 3 +export const VALIDATION_QUALITY_THRESHOLD = 8 export const RETRY_TIMEOUT_CONFIG = { multiplier: 2, diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts index d381c290..70ec967c 100644 --- a/src/services/ai/ai-types.ts +++ b/src/services/ai/ai-types.ts @@ -22,6 +22,8 @@ export interface AIDesignRequest { model?: string provider?: AIProviderType concurrency?: number + /** Generation mode: 'direct' uses current pipeline, 'visual-ref' uses visual reference pipeline */ + mode?: 'direct' | 'visual-ref' context?: { selectedNodes?: string[] documentSummary?: string @@ -31,6 +33,49 @@ export interface AIDesignRequest { } } +// --------------------------------------------------------------------------- +// Design System types — generated design tokens for consistency +// --------------------------------------------------------------------------- + +export interface DesignSystem { + palette: { + background: string + surface: string + text: string + textSecondary: string + primary: string + primaryLight: string + accent: string + border: string + } + typography: { + headingFont: string + bodyFont: string + scale: number[] + } + spacing: { + unit: number + scale: number[] + } + radius: number[] + aesthetic: string +} + +// --------------------------------------------------------------------------- +// Visual Reference types — for the visual reference pipeline +// --------------------------------------------------------------------------- + +export interface VisualReference { + /** Generated HTML/CSS code */ + html: string + /** Screenshot of the rendered HTML (base64 PNG, no data: prefix) */ + screenshot: string + /** Design system tokens used */ + designSystem: DesignSystem + /** Structural summary extracted from the HTML */ + structureSummary: string +} + export interface AICodeRequest { prompt?: string format: 'react-tailwind' | 'html-css' | 'react-inline' @@ -62,6 +107,8 @@ export interface SubTask { parentFrameId: string | null /** Screen/page grouping — subtasks with the same screen share one root frame */ screen?: string + /** HTML reference snippet for this section (from visual reference pipeline) */ + htmlReference?: string } /** Style guide produced by the orchestrator for visual consistency */ diff --git a/src/services/ai/design-canvas-ops.ts b/src/services/ai/design-canvas-ops.ts index 301196c0..0b5745f3 100644 --- a/src/services/ai/design-canvas-ops.ts +++ b/src/services/ai/design-canvas-ops.ts @@ -13,6 +13,7 @@ import { createPhonePlaceholderDataUri, estimateNodeIntrinsicHeight, } from './generation-utils' +import { defaultLineHeight } from '@/canvas/canvas-text-measure' import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons, resolveAllPendingIcons } from './icon-resolver' import { resolveNodeRole, @@ -44,10 +45,15 @@ let generationCanvasWidth = 1200 /** Root frame ID for the current generation — may differ from DEFAULT_FRAME_ID * when canvas already has content and new content is placed beside it. */ let generationRootFrameId: string = DEFAULT_FRAME_ID +/** Node IDs that existed on canvas before the current generation started. + * Used by upsert sanitization to avoid ID collisions with pre-existing content. */ +let preExistingNodeIds = new Set() export function resetGenerationRemapping(): void { generationRemappedIds.clear() generationRootFrameId = DEFAULT_FRAME_ID + // Snapshot all existing node IDs so upsert can avoid collisions + preExistingNodeIds = new Set(useDocumentStore.getState().getFlatNodes().map((n) => n.id)) } export function setGenerationContextHint(hint?: string): void { @@ -158,8 +164,7 @@ export function insertStreamingNode( } // Default lineHeight based on text role (heading vs body) if (!node.lineHeight) { - const fs = node.fontSize ?? 16 - node.lineHeight = fs >= 28 ? 1.2 : 1.5 + node.lineHeight = defaultLineHeight(node.fontSize ?? 16) } } } @@ -174,6 +179,11 @@ export function insertStreamingNode( applyGenerationHeuristics(node) + // Recursively remove x/y from children inside layout containers so the + // layout engine can position them correctly during canvas sync. + const parentHasLayout = parentNode ? hasActiveLayout(parentNode) : false + sanitizeLayoutChildPositions(node, parentHasLayout) + // Skip AI-streamed children under phone placeholders. Placeholder internals are // normalized post-streaming (at most one centered label text is allowed). // Also skip if the parent node doesn't exist on canvas (was itself blocked). @@ -624,10 +634,20 @@ function sanitizeNodesForUpsert(nodes: PenNode[]): PenNode[] { sanitizeScreenFrameBounds(node) } + // Start with pre-existing node IDs to avoid collisions with content + // that was on canvas before this generation started. IDs generated + // within the current batch are also tracked so siblings stay unique. + // Record remappings so progressive upsert can resolve renamed IDs. const counters = new Map() - const used = new Set() + const used = new Set(preExistingNodeIds) + const newRemaps = new Map() for (const node of cloned) { - ensureUniqueNodeIds(node, used, counters) + ensureUniqueNodeIds(node, used, counters, newRemaps) + } + + // Merge new remappings into the generation-wide remap table + for (const [from, to] of newRemaps) { + generationRemappedIds.set(from, to) } return cloned diff --git a/src/services/ai/design-code-generator.ts b/src/services/ai/design-code-generator.ts new file mode 100644 index 00000000..925df31a --- /dev/null +++ b/src/services/ai/design-code-generator.ts @@ -0,0 +1,170 @@ +/** + * Design Code Generator (Stage 1 of visual reference pipeline). + * + * Generates self-contained HTML/CSS code using the model's strongest design + * capability. The output is a visual reference that guides PenNode generation. + * Design principles are included to ensure consistent visual quality. + */ + +import type { DesignSystem } from './ai-types' +import type { AIProviderType } from '@/types/agent-settings' +import { generateCompletion } from './ai-service' +import { + DESIGN_CODE_SYSTEM_PROMPT, + buildCodeGenUserPrompt, +} from './design-code-prompts' +import { getAllPrinciples } from './design-principles' +import { designSystemToPromptContext } from './design-system-generator' + +interface CodeGenOptions { + width: number + height: number + model?: string + provider?: AIProviderType +} + +/** + * Generate self-contained HTML/CSS code for a design request. + * The code is production-grade and serves as a visual blueprint. + */ +export async function generateDesignCode( + prompt: string, + designSystem: DesignSystem, + options: CodeGenOptions, +): Promise { + const principles = getAllPrinciples() + + // Build the system prompt with principles injected + const systemPrompt = principles + ? `${DESIGN_CODE_SYSTEM_PROMPT}\n\n${principles}` + : DESIGN_CODE_SYSTEM_PROMPT + + // Build the user prompt with design system context + const dsContext = designSystemToPromptContext(designSystem) + const userPrompt = buildCodeGenUserPrompt( + prompt, + dsContext, + options.width, + options.height, + ) + + const response = await generateCompletion( + systemPrompt, + userPrompt, + options.model, + options.provider, + ) + + return extractHtmlFromResponse(response) +} + +/** + * Extract the HTML content from an AI response. + * Handles responses with code fences, markdown, or bare HTML. + */ +function extractHtmlFromResponse(response: string): string { + const trimmed = response.trim() + + // Check for code fence wrapped HTML + const fenceMatch = trimmed.match(/```(?:html)?\s*\n?([\s\S]*?)\n?```/) + if (fenceMatch) { + const content = fenceMatch[1].trim() + if (content.includes(')/i) + if (htmlMatch) { + return htmlMatch[1] + } + + // Last resort: wrap bare content + return ` + +Design +${trimmed} +` +} + +/** + * Extract a structural summary from HTML for use as sub-agent reference. + * Produces a concise text description of the HTML structure. + */ +export function extractStructureSummary(html: string): string { + const lines: string[] = ['DESIGN REFERENCE STRUCTURE:'] + + // Extract section-level elements + const sectionPattern = /<(?:section|header|footer|nav|main|div)\s+[^>]*(?:class|id)="([^"]*)"[^>]*>/gi + let match: RegExpExecArray | null + while ((match = sectionPattern.exec(html)) !== null) { + const classOrId = match[1] + if (classOrId && !classOrId.includes('__')) { + lines.push(`- Section: ${classOrId}`) + } + } + + // Extract heading content for structure hints + const headingPattern = /]*>([\s\S]*?)<\/h\1>/gi + while ((match = headingPattern.exec(html)) !== null) { + const level = match[1] + const content = match[2].replace(/<[^>]+>/g, '').trim().slice(0, 60) + if (content) { + lines.push(`- H${level}: "${content}"`) + } + } + + // Extract button/CTA text + const buttonPattern = /<(?:button|a)\s+[^>]*class="[^"]*(?:btn|button|cta)[^"]*"[^>]*>([\s\S]*?)<\/(?:button|a)>/gi + while ((match = buttonPattern.exec(html)) !== null) { + const text = match[1].replace(/<[^>]+>/g, '').trim().slice(0, 30) + if (text) { + lines.push(`- CTA: "${text}"`) + } + } + + // If we couldn't extract structure, provide a generic summary + if (lines.length <= 1) { + lines.push('(HTML structure extracted — use as visual layout reference)') + } + + return lines.join('\n') +} + +/** + * Extract the HTML section relevant to a specific subtask label. + * Uses heuristic matching on section/div IDs, classes, and heading content. + */ +export function extractHtmlSection(html: string, subtaskLabel: string): string | null { + const labelLower = subtaskLabel.toLowerCase() + + // Try to find a matching section by common keywords + const keywords = labelLower + .replace(/[((].+[))]/g, '') + .split(/[\s,/]+/) + .filter((w) => w.length > 2) + + if (keywords.length === 0) return null + + // Build a regex to match section containers + const keywordPattern = keywords.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + const sectionRegex = new RegExp( + `<(?:section|div|header|footer|nav)[^>]*(?:class|id)="[^"]*(?:${keywordPattern})[^"]*"[^>]*>[\\s\\S]*?(?=<(?:section|div|header|footer|nav)[^>]*(?:class|id)="|$)`, + 'i', + ) + + const match = sectionRegex.exec(html) + if (match) { + // Truncate to reasonable length for context + const section = match[0].slice(0, 1500) + return `HTML reference for "${subtaskLabel}":\n${section}` + } + + return null +} diff --git a/src/services/ai/design-code-prompts.ts b/src/services/ai/design-code-prompts.ts new file mode 100644 index 00000000..e0a1308a --- /dev/null +++ b/src/services/ai/design-code-prompts.ts @@ -0,0 +1,67 @@ +/** + * Prompts for HTML/CSS design code generation (Stage 1 of visual reference pipeline). + * + * These prompts leverage the model's strongest design capability — HTML/CSS generation — + * to produce a high-fidelity visual reference. The generated HTML serves as a blueprint + * that is later converted to PenNode format. + */ + +export const DESIGN_CODE_SYSTEM_PROMPT = `You are a world-class frontend designer. Generate a SINGLE self-contained HTML file that looks production-grade. + +OUTPUT RULES: +- Output ONLY the complete HTML file, starting with . No explanation. +- ALL CSS must be inline in a +` +} + +export function generateSvelteFromDocument(doc: PenDocument, activePageId?: string | null): string { + const children = activePageId !== undefined ? getActivePageChildren(doc, activePageId) : doc.children + return generateSvelteCode(children) +} diff --git a/src/services/codegen/swiftui-generator.ts b/src/services/codegen/swiftui-generator.ts new file mode 100644 index 00000000..0efcdc57 --- /dev/null +++ b/src/services/codegen/swiftui-generator.ts @@ -0,0 +1,746 @@ +import type { PenDocument, PenNode, ContainerProps, TextNode, ImageNode, LineNode, PathNode, PolygonNode } from '@/types/pen' +import { getActivePageChildren } from '@/stores/document-tree-utils' +import type { PenFill, PenStroke, PenEffect, ShadowEffect } from '@/types/styles' +import { isVariableRef } from '@/variables/resolve-variables' +import { variableNameToCSS } from '@/services/codegen/css-variables-generator' + +/** + * Converts PenDocument nodes to SwiftUI code. + * $variable references are output as var(--name) comments for manual mapping. + */ + +/** Convert a `$variable` ref to a placeholder comment, or return the raw value. */ +function varOrLiteral(value: string): string { + if (isVariableRef(value)) { + return `var(${variableNameToCSS(value.slice(1))})` + } + return value +} + +function indent(depth: number): string { + return ' '.repeat(depth) +} + +/** Parse a hex color string to SwiftUI Color initializer. */ +function hexToSwiftUIColor(hex: string): string { + if (hex.startsWith('$')) { + return `Color("${varOrLiteral(hex)}") /* variable */` + } + const clean = hex.replace('#', '') + if (clean.length === 6) { + const r = parseInt(clean.substring(0, 2), 16) / 255 + const g = parseInt(clean.substring(2, 4), 16) / 255 + const b = parseInt(clean.substring(4, 6), 16) / 255 + return `Color(red: ${r.toFixed(3)}, green: ${g.toFixed(3)}, blue: ${b.toFixed(3)})` + } + if (clean.length === 8) { + const r = parseInt(clean.substring(0, 2), 16) / 255 + const g = parseInt(clean.substring(2, 4), 16) / 255 + const b = parseInt(clean.substring(4, 6), 16) / 255 + const a = parseInt(clean.substring(6, 8), 16) / 255 + return `Color(red: ${r.toFixed(3)}, green: ${g.toFixed(3)}, blue: ${b.toFixed(3)}).opacity(${a.toFixed(3)})` + } + return `Color("${hex}")` +} + +function fillToSwiftUI(fills: PenFill[] | undefined): string | null { + if (!fills || fills.length === 0) return null + const fill = fills[0] + if (fill.type === 'solid') { + return hexToSwiftUIColor(fill.color) + } + if (fill.type === 'linear_gradient') { + if (!fill.stops?.length) return null + const angle = fill.angle ?? 180 + const startPoint = angleToUnitPoint(angle, 'start') + const endPoint = angleToUnitPoint(angle, 'end') + const stops = fill.stops + .map((s) => `.init(color: ${hexToSwiftUIColor(s.color)}, location: ${s.offset.toFixed(2)})`) + .join(', ') + return `LinearGradient(stops: [${stops}], startPoint: ${startPoint}, endPoint: ${endPoint})` + } + if (fill.type === 'radial_gradient') { + if (!fill.stops?.length) return null + const stops = fill.stops + .map((s) => `.init(color: ${hexToSwiftUIColor(s.color)}, location: ${s.offset.toFixed(2)})`) + .join(', ') + return `RadialGradient(stops: [${stops}], center: .center, startRadius: 0, endRadius: 100)` + } + return null +} + +/** Convert an angle in degrees to SwiftUI UnitPoint for gradient start/end. */ +function angleToUnitPoint(angle: number, point: 'start' | 'end'): string { + const normalized = ((angle % 360) + 360) % 360 + if (point === 'start') { + if (normalized === 0) return '.bottom' + if (normalized === 90) return '.leading' + if (normalized === 180) return '.top' + if (normalized === 270) return '.trailing' + return `.top` + } + // end + if (normalized === 0) return '.top' + if (normalized === 90) return '.trailing' + if (normalized === 180) return '.bottom' + if (normalized === 270) return '.leading' + return `.bottom` +} + +function strokeToSwiftUI( + stroke: PenStroke | undefined, + cornerRadius: number | [number, number, number, number] | undefined, +): string[] { + if (!stroke) return [] + const modifiers: string[] = [] + const thickness = typeof stroke.thickness === 'number' + ? stroke.thickness + : typeof stroke.thickness === 'string' + ? stroke.thickness + : stroke.thickness[0] + const thicknessStr = typeof thickness === 'string' && isVariableRef(thickness) + ? `/* ${varOrLiteral(thickness)} */ 1` + : String(thickness) + + let strokeColor = 'Color.gray' + if (stroke.fill && stroke.fill.length > 0) { + const sf = stroke.fill[0] + if (sf.type === 'solid') { + strokeColor = hexToSwiftUIColor(sf.color) + } + } + + const cr = typeof cornerRadius === 'number' ? cornerRadius : 0 + if (cr > 0) { + modifiers.push(`.overlay(RoundedRectangle(cornerRadius: ${cr}).stroke(${strokeColor}, lineWidth: ${thicknessStr}))`) + } else { + modifiers.push(`.overlay(Rectangle().stroke(${strokeColor}, lineWidth: ${thicknessStr}))`) + } + return modifiers +} + +function effectsToSwiftUI(effects: PenEffect[] | undefined): string[] { + if (!effects || effects.length === 0) return [] + const modifiers: string[] = [] + for (const effect of effects) { + if (effect.type === 'shadow') { + const s = effect as ShadowEffect + modifiers.push(`.shadow(color: ${hexToSwiftUIColor(s.color)}, radius: ${s.blur}, x: ${s.offsetX}, y: ${s.offsetY})`) + } else if (effect.type === 'blur' || effect.type === 'background_blur') { + modifiers.push(`.blur(radius: ${effect.radius})`) + } + } + return modifiers +} + +function paddingToSwiftUI( + padding: number | [number, number] | [number, number, number, number] | string | undefined, +): string[] { + if (padding === undefined) return [] + if (typeof padding === 'string' && isVariableRef(padding)) { + return [`.padding(/* ${varOrLiteral(padding)} */ 0)`] + } + if (typeof padding === 'number') { + return padding > 0 ? [`.padding(${padding})`] : [] + } + if (Array.isArray(padding)) { + if (padding.length === 2) { + const modifiers: string[] = [] + if (padding[0] > 0) modifiers.push(`.padding(.vertical, ${padding[0]})`) + if (padding[1] > 0) modifiers.push(`.padding(.horizontal, ${padding[1]})`) + return modifiers + } + if (padding.length === 4) { + const [top, trailing, bottom, leading] = padding + const modifiers: string[] = [] + if (top > 0) modifiers.push(`.padding(.top, ${top})`) + if (trailing > 0) modifiers.push(`.padding(.trailing, ${trailing})`) + if (bottom > 0) modifiers.push(`.padding(.bottom, ${bottom})`) + if (leading > 0) modifiers.push(`.padding(.leading, ${leading})`) + return modifiers + } + } + return [] +} + +function getTextContent(node: TextNode): string { + if (typeof node.content === 'string') return node.content + return node.content.map((s) => s.text).join('') +} + +function escapeSwiftString(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') +} + +function fontWeightToSwiftUI(weight: number | string | undefined): string | null { + if (weight === undefined) return null + const w = typeof weight === 'number' ? weight : parseInt(weight, 10) + if (isNaN(w)) return null + if (w <= 100) return '.ultraLight' + if (w <= 200) return '.thin' + if (w <= 300) return '.light' + if (w <= 400) return '.regular' + if (w <= 500) return '.medium' + if (w <= 600) return '.semibold' + if (w <= 700) return '.bold' + if (w <= 800) return '.heavy' + return '.black' +} + +function textAlignToSwiftUI(align: string | undefined): string | null { + if (!align) return null + const map: Record = { + left: '.leading', + center: '.center', + right: '.trailing', + } + return map[align] ?? null +} + +function alignToSwiftUI( + alignItems: string | undefined, + layout: string | undefined, +): string | null { + if (!alignItems || !layout || layout === 'none') return null + if (layout === 'vertical') { + const map: Record = { + start: '.leading', + center: '.center', + end: '.trailing', + } + return map[alignItems] ?? null + } + // horizontal layout: alignment is vertical + const map: Record = { + start: '.top', + center: '.center', + end: '.bottom', + } + return map[alignItems] ?? null +} + +/** Render a node and its modifiers, returning an array of lines. */ +function generateNodeSwiftUI(node: PenNode, depth: number): string { + const pad = indent(depth) + + switch (node.type) { + case 'frame': + case 'rectangle': + case 'group': { + return generateContainerSwiftUI(node, depth) + } + + case 'ellipse': { + const modifiers: string[] = [] + const fillStr = fillToSwiftUI(node.fill) + if (fillStr) { + modifiers.push(`.fill(${fillStr})`) + } + if (typeof node.width === 'number' || typeof node.height === 'number') { + const w = typeof node.width === 'number' ? node.width : (typeof node.height === 'number' ? node.height : 100) + const h = typeof node.height === 'number' ? node.height : w + modifiers.push(`.frame(width: ${w}, height: ${h})`) + } + modifiers.push(...strokeToSwiftUI(node.stroke, undefined)) + modifiers.push(...effectsToSwiftUI(node.effects)) + modifiers.push(...commonModifiers(node)) + + return renderWithModifiers(pad, 'Ellipse()', modifiers) + } + + case 'text': { + return generateTextSwiftUI(node, depth) + } + + case 'line': { + return generateLineSwiftUI(node, depth) + } + + case 'polygon': + case 'path': { + return generatePathSwiftUI(node, depth) + } + + case 'image': { + return generateImageSwiftUI(node, depth) + } + + case 'ref': + return `${pad}// Ref: ${node.ref}` + + default: + return `${pad}// Unsupported node type` + } +} + +function commonModifiers(node: PenNode): string[] { + const modifiers: string[] = [] + + if (node.opacity !== undefined && node.opacity !== 1) { + if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) { + modifiers.push(`.opacity(/* ${varOrLiteral(node.opacity)} */ 1.0)`) + } else if (typeof node.opacity === 'number') { + modifiers.push(`.opacity(${node.opacity})`) + } + } + + if (node.rotation) { + modifiers.push(`.rotationEffect(.degrees(${node.rotation}))`) + } + + if (node.x !== undefined || node.y !== undefined) { + const x = node.x ?? 0 + const y = node.y ?? 0 + modifiers.push(`.offset(x: ${x}, y: ${y})`) + } + + return modifiers +} + +function renderWithModifiers( + pad: string, + element: string, + modifiers: string[], +): string { + if (modifiers.length === 0) { + return `${pad}${element}` + } + const lines = [`${pad}${element}`] + for (const mod of modifiers) { + lines.push(`${pad} ${mod}`) + } + return lines.join('\n') +} + +function generateContainerSwiftUI( + node: PenNode & ContainerProps, + depth: number, +): string { + const pad = indent(depth) + const children = node.children ?? [] + const hasLayout = node.layout === 'vertical' || node.layout === 'horizontal' + const cr = typeof node.cornerRadius === 'number' ? node.cornerRadius : 0 + + // Determine stack type + let stackType: string + let stackArgs = '' + if (node.layout === 'vertical') { + const alignment = alignToSwiftUI(node.alignItems, node.layout) + const spacingStr = gapToSwiftUI(node.gap) + const args: string[] = [] + if (alignment) args.push(`alignment: ${alignment}`) + if (spacingStr) args.push(`spacing: ${spacingStr}`) + stackType = 'VStack' + if (args.length > 0) stackArgs = `(${args.join(', ')})` + } else if (node.layout === 'horizontal') { + const alignment = alignToSwiftUI(node.alignItems, node.layout) + const spacingStr = gapToSwiftUI(node.gap) + const args: string[] = [] + if (alignment) args.push(`alignment: ${alignment}`) + if (spacingStr) args.push(`spacing: ${spacingStr}`) + stackType = 'HStack' + if (args.length > 0) stackArgs = `(${args.join(', ')})` + } else { + stackType = 'ZStack' + } + + // Build modifiers + const modifiers: string[] = [] + + modifiers.push(...paddingToSwiftUI(node.padding)) + + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + modifiers.push(`.frame(${args.join(', ')})`) + } + + const fillStr = fillToSwiftUI(node.fill) + if (fillStr) { + if (cr > 0) { + modifiers.push(`.background(${fillStr})`) + modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`) + } else { + modifiers.push(`.background(${fillStr})`) + } + } else if (cr > 0) { + modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`) + } + + modifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius)) + modifiers.push(...effectsToSwiftUI(node.effects)) + + if (node.clipContent) { + modifiers.push('.clipped()') + } + + modifiers.push(...commonModifiers(node)) + + // No children: render as a shape + if (children.length === 0 && !hasLayout) { + if (fillStr && cr > 0) { + const shapeModifiers: string[] = [] + shapeModifiers.push(`.fill(${fillStr})`) + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + shapeModifiers.push(`.frame(${args.join(', ')})`) + } + shapeModifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius)) + shapeModifiers.push(...effectsToSwiftUI(node.effects)) + shapeModifiers.push(...commonModifiers(node)) + return renderWithModifiers(pad, `RoundedRectangle(cornerRadius: ${cr})`, shapeModifiers) + } + if (fillStr) { + const shapeModifiers: string[] = [] + shapeModifiers.push(`.fill(${fillStr})`) + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + shapeModifiers.push(`.frame(${args.join(', ')})`) + } + shapeModifiers.push(...strokeToSwiftUI(node.stroke, node.cornerRadius)) + shapeModifiers.push(...effectsToSwiftUI(node.effects)) + shapeModifiers.push(...commonModifiers(node)) + return renderWithModifiers(pad, 'Rectangle()', shapeModifiers) + } + // Empty container with just size/modifiers + const emptyLines = [`${pad}${stackType}${stackArgs} {}`] + for (const mod of modifiers) { + emptyLines.push(`${pad} ${mod}`) + } + return emptyLines.join('\n') + } + + // With children + const comment = node.name ? `${pad}// ${node.name}\n` : '' + const childLines = children + .map((c) => generateNodeSwiftUI(c, depth + 1)) + .join('\n') + + const lines = [`${comment}${pad}${stackType}${stackArgs} {`] + lines.push(childLines) + lines.push(`${pad}}`) + for (const mod of modifiers) { + lines.push(`${pad} ${mod}`) + } + return lines.join('\n') +} + +function gapToSwiftUI(gap: number | string | undefined): string | null { + if (gap === undefined) return null + if (typeof gap === 'string' && isVariableRef(gap)) { + return `/* ${varOrLiteral(gap)} */ 0` + } + if (typeof gap === 'number' && gap > 0) { + return String(gap) + } + return null +} + +function generateTextSwiftUI(node: TextNode, depth: number): string { + const pad = indent(depth) + const text = escapeSwiftString(getTextContent(node)) + const modifiers: string[] = [] + + // Font + const weight = fontWeightToSwiftUI(node.fontWeight) + if (node.fontSize && weight) { + modifiers.push(`.font(.system(size: ${node.fontSize}, weight: ${weight}))`) + } else if (node.fontSize) { + modifiers.push(`.font(.system(size: ${node.fontSize}))`) + } + + // Font style + if (node.fontStyle === 'italic') { + modifiers.push('.italic()') + } + + // Text color + if (node.fill && node.fill.length > 0) { + const fill = node.fill[0] + if (fill.type === 'solid') { + modifiers.push(`.foregroundColor(${hexToSwiftUIColor(fill.color)})`) + } + } + + // Alignment + const align = textAlignToSwiftUI(node.textAlign) + if (align) { + modifiers.push(`.multilineTextAlignment(${align})`) + } + + // Frame / sizing + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') { + args.push(`width: ${node.width}`) + } + if (typeof node.height === 'number') { + args.push(`height: ${node.height}`) + } + if (node.textAlign === 'left') args.push('alignment: .leading') + else if (node.textAlign === 'right') args.push('alignment: .trailing') + modifiers.push(`.frame(${args.join(', ')})`) + } + + // Letter spacing + if (node.letterSpacing) { + modifiers.push(`.kerning(${node.letterSpacing})`) + } + + // Line height (approximation via lineSpacing) + if (node.lineHeight && node.fontSize) { + const spacing = node.lineHeight * node.fontSize - node.fontSize + if (spacing > 0) { + modifiers.push(`.lineSpacing(${spacing.toFixed(1)})`) + } + } + + // Decorations + if (node.underline) { + modifiers.push('.underline()') + } + if (node.strikethrough) { + modifiers.push('.strikethrough()') + } + + modifiers.push(...effectsToSwiftUI(node.effects)) + modifiers.push(...commonModifiers(node)) + + return renderWithModifiers(pad, `Text("${text}")`, modifiers) +} + +function generateLineSwiftUI(node: LineNode, depth: number): string { + const pad = indent(depth) + const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0 + const modifiers: string[] = [] + + if (w > 0) { + modifiers.push(`.frame(width: ${w}, height: 1)`) + } else { + modifiers.push('.frame(height: 1)') + } + + if (node.stroke && node.stroke.fill && node.stroke.fill.length > 0) { + const sf = node.stroke.fill[0] + if (sf.type === 'solid') { + modifiers.push(`.background(${hexToSwiftUIColor(sf.color)})`) + } + } else { + modifiers.push('.background(Color.gray)') + } + + modifiers.push(...commonModifiers(node)) + + return renderWithModifiers(pad, 'Rectangle()', modifiers) +} + +function generatePathSwiftUI(node: PathNode | PolygonNode, depth: number): string { + const pad = indent(depth) + + if (node.type === 'path') { + const fillStr = fillToSwiftUI(node.fill) + const fillColor = fillStr ?? 'Color.primary' + const modifiers: string[] = [] + + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + modifiers.push(`.frame(${args.join(', ')})`) + } + modifiers.push(...effectsToSwiftUI(node.effects)) + modifiers.push(...commonModifiers(node)) + + const escapedD = escapeSwiftString(node.d) + + const lines = [ + `${pad}// ${node.name ?? 'Path'}`, + `${pad}SVGPath("${escapedD}")`, + `${pad} .fill(${fillColor})`, + ] + for (const mod of modifiers) { + lines.push(`${pad} ${mod}`) + } + return lines.join('\n') + } + + // Polygon + const modifiers: string[] = [] + const fillStr = fillToSwiftUI(node.fill) + if (fillStr) { + modifiers.push(`.fill(${fillStr})`) + } + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + modifiers.push(`.frame(${args.join(', ')})`) + } + modifiers.push(...effectsToSwiftUI(node.effects)) + modifiers.push(...commonModifiers(node)) + + const sides = node.polygonCount + return renderWithModifiers(pad, `PolygonShape(sides: ${sides})`, modifiers) +} + +function generateImageSwiftUI(node: ImageNode, depth: number): string { + const pad = indent(depth) + const modifiers: string[] = [] + + // Resizing + modifiers.push('.resizable()') + if (node.objectFit === 'fit') { + modifiers.push('.aspectRatio(contentMode: .fit)') + } else { + modifiers.push('.aspectRatio(contentMode: .fill)') + } + + if (typeof node.width === 'number' || typeof node.height === 'number') { + const args: string[] = [] + if (typeof node.width === 'number') args.push(`width: ${node.width}`) + if (typeof node.height === 'number') args.push(`height: ${node.height}`) + modifiers.push(`.frame(${args.join(', ')})`) + } + + if (node.cornerRadius) { + const cr = typeof node.cornerRadius === 'number' ? node.cornerRadius : node.cornerRadius[0] + if (cr > 0) { + modifiers.push(`.clipShape(RoundedRectangle(cornerRadius: ${cr}))`) + } + } + + modifiers.push(...effectsToSwiftUI(node.effects)) + modifiers.push(...commonModifiers(node)) + + const src = node.src + + // Data URI — extract base64 and decode at runtime + if (src.startsWith('data:image/')) { + const base64Start = src.indexOf('base64,') + if (base64Start !== -1) { + const base64Data = src.slice(base64Start + 7) + const truncated = base64Data.length > 80 ? base64Data.substring(0, 80) + '...' : base64Data + const lines = [ + `${pad}// Embedded image (${node.name ?? 'image'})`, + `${pad}// Base64 data: ${truncated}`, + `${pad}if let data = Data(base64Encoded: "${base64Data}"),`, + `${pad} let uiImage = UIImage(data: data) {`, + `${pad} Image(uiImage: uiImage)`, + ] + for (const mod of modifiers) { + lines.push(`${pad} ${mod}`) + } + lines.push(`${pad}}`) + return lines.join('\n') + } + } + + const escapedSrc = escapeSwiftString(src) + if (src.startsWith('http://') || src.startsWith('https://')) { + const lines = [ + `${pad}AsyncImage(url: URL(string: "${escapedSrc}")) { image in`, + `${pad} image`, + ] + for (const mod of modifiers) { + lines.push(`${pad} ${mod}`) + } + lines.push(`${pad}} placeholder: {`) + lines.push(`${pad} ProgressView()`) + lines.push(`${pad}}`) + return lines.join('\n') + } + + return renderWithModifiers(pad, `Image("${escapedSrc}")`, modifiers) +} + +export function generateSwiftUICode( + nodes: PenNode[], + viewName = 'GeneratedView', +): string { + if (nodes.length === 0) { + return `import SwiftUI\n\nstruct ${viewName}: View {\n var body: some View {\n EmptyView()\n }\n}\n` + } + + // Compute wrapper size for root ZStack + let maxW = 0 + let maxH = 0 + for (const node of nodes) { + const x = node.x ?? 0 + const y = node.y ?? 0 + const w = 'width' in node && typeof node.width === 'number' ? node.width : 0 + const h = 'height' in node && typeof node.height === 'number' ? node.height : 0 + maxW = Math.max(maxW, x + w) + maxH = Math.max(maxH, y + h) + } + + const childLines = nodes + .map((n) => generateNodeSwiftUI(n, 3)) + .join('\n') + + const frameArgs: string[] = [] + if (maxW > 0) frameArgs.push(`width: ${maxW}`) + if (maxH > 0) frameArgs.push(`height: ${maxH}`) + const frameModifier = frameArgs.length > 0 ? `\n .frame(${frameArgs.join(', ')})` : '' + + return `import SwiftUI + +/// Helper: parses SVG path data into a SwiftUI Shape. +/// Usage: SVGPath("M10 20 L30 40 Z").fill(.red) +struct SVGPath: Shape { + let pathData: String + init(_ pathData: String) { self.pathData = pathData } + func path(in rect: CGRect) -> Path { + // Use a third-party SVG path parser or implement command parsing + // For production, consider using SwiftSVG or similar library + Path { _ in /* parse pathData here */ } + } +} + +/// Helper: regular polygon shape +struct PolygonShape: Shape { + let sides: Int + func path(in rect: CGRect) -> Path { + let center = CGPoint(x: rect.midX, y: rect.midY) + let radius = min(rect.width, rect.height) / 2 + var path = Path() + for i in 0.. + scoped CSS. + */ + +function varOrLiteral(value: string): string { + if (isVariableRef(value)) { + return `var(${variableNameToCSS(value.slice(1))})` + } + return value +} + +function indent(depth: number): string { + return ' '.repeat(depth) +} + +// --------------------------------------------------------------------------- +// CSS helpers (reused from html-generator pattern) +// --------------------------------------------------------------------------- + +function fillToCSS(fills: PenFill[] | undefined): Record { + if (!fills || fills.length === 0) return {} + const fill = fills[0] + if (fill.type === 'solid') return { background: varOrLiteral(fill.color) } + if (fill.type === 'linear_gradient') { + if (!fill.stops?.length) return {} + const angle = fill.angle ?? 180 + const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ') + return { background: `linear-gradient(${angle}deg, ${stops})` } + } + if (fill.type === 'radial_gradient') { + if (!fill.stops?.length) return {} + const stops = fill.stops.map((s) => `${varOrLiteral(s.color)} ${Math.round(s.offset * 100)}%`).join(', ') + return { background: `radial-gradient(circle, ${stops})` } + } + return {} +} + +function strokeToCSS(stroke: PenStroke | undefined): Record { + if (!stroke) return {} + const css: Record = {} + if (typeof stroke.thickness === 'string' && isVariableRef(stroke.thickness)) { + css['border-width'] = varOrLiteral(stroke.thickness) + } else { + const t = typeof stroke.thickness === 'number' ? stroke.thickness : stroke.thickness[0] + css['border-width'] = `${t}px` + } + css['border-style'] = 'solid' + if (stroke.fill && stroke.fill.length > 0) { + const sf = stroke.fill[0] + if (sf.type === 'solid') css['border-color'] = varOrLiteral(sf.color) + } + return css +} + +function effectsToCSS(effects: PenEffect[] | undefined): Record { + if (!effects || effects.length === 0) return {} + const shadows: string[] = [] + for (const effect of effects) { + if (effect.type === 'shadow') { + const s = effect as ShadowEffect + const inset = s.inner ? 'inset ' : '' + shadows.push(`${inset}${s.offsetX}px ${s.offsetY}px ${s.blur}px ${s.spread}px ${s.color}`) + } + } + return shadows.length > 0 ? { 'box-shadow': shadows.join(', ') } : {} +} + +function cornerRadiusToCSS(cr: number | [number, number, number, number] | undefined): Record { + if (cr === undefined) return {} + if (typeof cr === 'number') return cr === 0 ? {} : { 'border-radius': `${cr}px` } + return { 'border-radius': `${cr[0]}px ${cr[1]}px ${cr[2]}px ${cr[3]}px` } +} + +function layoutToCSS(node: ContainerProps): Record { + const css: Record = {} + if (node.layout === 'vertical') { css.display = 'flex'; css['flex-direction'] = 'column' } + else if (node.layout === 'horizontal') { css.display = 'flex'; css['flex-direction'] = 'row' } + if (node.gap !== undefined) { + css.gap = typeof node.gap === 'string' && isVariableRef(node.gap) + ? varOrLiteral(node.gap) + : typeof node.gap === 'number' ? `${node.gap}px` : '' + } + if (node.padding !== undefined) { + if (typeof node.padding === 'string' && isVariableRef(node.padding)) css.padding = varOrLiteral(node.padding) + else if (typeof node.padding === 'number') css.padding = `${node.padding}px` + else if (Array.isArray(node.padding)) css.padding = node.padding.map((p) => `${p}px`).join(' ') + } + if (node.justifyContent) { + const map: Record = { start: 'flex-start', center: 'center', end: 'flex-end', space_between: 'space-between', space_around: 'space-around' } + css['justify-content'] = map[node.justifyContent] ?? node.justifyContent + } + if (node.alignItems) { + const map: Record = { start: 'flex-start', center: 'center', end: 'flex-end' } + css['align-items'] = map[node.alignItems] ?? node.alignItems + } + if (node.clipContent) css.overflow = 'hidden' + return css +} + +// --------------------------------------------------------------------------- +// Node → template + CSS rule generation +// --------------------------------------------------------------------------- + +interface CSSRule { className: string; properties: Record } + +let classCounter = 0 +function resetClassCounter() { classCounter = 0 } +function nextClassName(prefix: string): string { return `${prefix}-${++classCounter}` } + +function escapeHTML(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +function getTextContent(node: TextNode): string { + if (typeof node.content === 'string') return node.content + return node.content.map((s) => s.text).join('') +} + +function generateNodeTemplate(node: PenNode, depth: number, rules: CSSRule[]): string { + const pad = indent(depth) + const css: Record = {} + + if (node.x !== undefined || node.y !== undefined) { + css.position = 'absolute' + if (node.x !== undefined) css.left = `${node.x}px` + if (node.y !== undefined) css.top = `${node.y}px` + } + if (node.opacity !== undefined && node.opacity !== 1) { + if (typeof node.opacity === 'string' && isVariableRef(node.opacity)) css.opacity = varOrLiteral(node.opacity) + else if (typeof node.opacity === 'number') css.opacity = String(node.opacity) + } + if (node.rotation) css.transform = `rotate(${node.rotation}deg)` + + switch (node.type) { + case 'frame': + case 'rectangle': + case 'group': { + if (typeof node.width === 'number') css.width = `${node.width}px` + if (typeof node.height === 'number') css.height = `${node.height}px` + Object.assign(css, fillToCSS(node.fill), strokeToCSS(node.stroke), cornerRadiusToCSS(node.cornerRadius), effectsToCSS(node.effects), layoutToCSS(node)) + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type) + rules.push({ className, properties: css }) + const children = node.children ?? [] + if (children.length === 0) return `${pad}
` + const childrenHTML = children.map((c) => generateNodeTemplate(c, depth + 1, rules)).join('\n') + return `${pad}
\n${childrenHTML}\n${pad}
` + } + + case 'ellipse': { + if (typeof node.width === 'number') css.width = `${node.width}px` + if (typeof node.height === 'number') css.height = `${node.height}px` + css['border-radius'] = '50%' + Object.assign(css, fillToCSS(node.fill), strokeToCSS(node.stroke), effectsToCSS(node.effects)) + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'ellipse') + rules.push({ className, properties: css }) + return `${pad}
` + } + + case 'text': { + if (typeof node.width === 'number') css.width = `${node.width}px` + if (typeof node.height === 'number') css.height = `${node.height}px` + if (node.fill) { const f = node.fill[0]; if (f?.type === 'solid') css.color = varOrLiteral(f.color) } + if (node.fontSize) css['font-size'] = `${node.fontSize}px` + if (node.fontWeight) css['font-weight'] = String(node.fontWeight) + if (node.fontStyle === 'italic') css['font-style'] = 'italic' + if (node.textAlign) css['text-align'] = node.textAlign + if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif` + if (node.lineHeight) css['line-height'] = String(node.lineHeight) + if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px` + if (node.underline) css['text-decoration'] = 'underline' + if (node.strikethrough) css['text-decoration'] = 'line-through' + Object.assign(css, effectsToCSS(node.effects)) + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'text') + rules.push({ className, properties: css }) + const size = node.fontSize ?? 16 + const tag = size >= 32 ? 'h1' : size >= 24 ? 'h2' : size >= 20 ? 'h3' : 'p' + return `${pad}<${tag} class="${className}">${escapeHTML(getTextContent(node))}` + } + + case 'line': { + const w = node.x2 !== undefined ? Math.abs(node.x2 - (node.x ?? 0)) : 0 + css.width = `${w}px` + if (node.stroke) { + const t = typeof node.stroke.thickness === 'number' ? node.stroke.thickness : node.stroke.thickness[0] + css['border-top-width'] = `${t}px` + css['border-top-style'] = 'solid' + if (node.stroke.fill?.[0]?.type === 'solid') css['border-top-color'] = varOrLiteral(node.stroke.fill[0].color) + } + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'line') + rules.push({ className, properties: css }) + return `${pad}
` + } + + case 'polygon': + case 'path': { + if (typeof node.width === 'number') css.width = `${node.width}px` + if (typeof node.height === 'number') css.height = `${node.height}px` + Object.assign(css, fillToCSS(node.fill)) + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? node.type) + rules.push({ className, properties: css }) + if (node.type === 'path') { + const w = typeof node.width === 'number' ? node.width : 100 + const h = typeof node.height === 'number' ? node.height : 100 + const fillColor = node.fill?.[0]?.type === 'solid' ? varOrLiteral(node.fill[0].color) : 'currentColor' + return `${pad}\n${pad} \n${pad}` + } + return `${pad}
` + } + + case 'image': { + if (typeof node.width === 'number') css.width = `${node.width}px` + if (typeof node.height === 'number') css.height = `${node.height}px` + css['object-fit'] = node.objectFit ?? 'fill' + Object.assign(css, cornerRadiusToCSS(node.cornerRadius)) + const className = nextClassName(node.name?.replace(/\s+/g, '-').toLowerCase() ?? 'image') + rules.push({ className, properties: css }) + return `${pad}${escapeHTML(node.name ?? '')}` + } + + case 'ref': + return `${pad}` + + default: + return `${pad}` + } +} + +function cssRulesToString(rules: CSSRule[]): string { + return rules.map((r) => { + const props = Object.entries(r.properties).map(([k, v]) => ` ${k}: ${v};`).join('\n') + return `.${r.className} {\n${props}\n}` + }).join('\n\n') +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export function generateVueCode(nodes: PenNode[], componentName = 'GeneratedDesign'): string { + resetClassCounter() + const rules: CSSRule[] = [] + + let maxW = 0, maxH = 0 + for (const node of nodes) { + const x = node.x ?? 0, y = node.y ?? 0 + const w = 'width' in node && typeof node.width === 'number' ? node.width : 0 + const h = 'height' in node && typeof node.height === 'number' ? node.height : 0 + maxW = Math.max(maxW, x + w) + maxH = Math.max(maxH, y + h) + } + + const containerCSS: Record = { position: 'relative' } + if (maxW > 0) containerCSS.width = `${maxW}px` + if (maxH > 0) containerCSS.height = `${maxH}px` + rules.push({ className: 'container', properties: containerCSS }) + + const template = nodes.length === 0 + ? '
' + : `
\n${nodes.map((n) => generateNodeTemplate(n, 3, rules)).join('\n')}\n
` + + const css = cssRulesToString(rules) + + return ` + + + + +` +} + +export function generateVueFromDocument(doc: PenDocument, activePageId?: string | null): string { + const children = activePageId !== undefined ? getActivePageChildren(doc, activePageId) : doc.children + return generateVueCode(children) +} diff --git a/src/stores/canvas-store.ts b/src/stores/canvas-store.ts index e00e309b..989fe378 100644 --- a/src/stores/canvas-store.ts +++ b/src/stores/canvas-store.ts @@ -11,10 +11,13 @@ import { DEFAULT_PAGE_ID } from '@/stores/document-tree-utils' const PREFS_KEY = 'openpencil-canvas-preferences' +export type RightPanelTab = 'design' | 'code' + interface CanvasPreferences { layerPanelOpen: boolean variablesPanelOpen: boolean codePanelOpen: boolean + rightPanelTab?: RightPanelTab } interface CanvasStoreState { @@ -27,6 +30,7 @@ interface CanvasStoreState { layerPanelOpen: boolean variablesPanelOpen: boolean codePanelOpen: boolean + rightPanelTab: RightPanelTab figmaImportDialogOpen: boolean activePageId: string | null @@ -46,6 +50,7 @@ interface CanvasStoreState { toggleVariablesPanel: () => void toggleCodePanel: () => void setCodePanelOpen: (open: boolean) => void + setRightPanelTab: (tab: RightPanelTab) => void setFigmaImportDialogOpen: (open: boolean) => void setActivePageId: (pageId: string | null) => void hydrate: () => void @@ -72,6 +77,7 @@ export const useCanvasStore = create((set, get) => ({ layerPanelOpen: true, variablesPanelOpen: false, codePanelOpen: false, + rightPanelTab: 'design', figmaImportDialogOpen: false, activePageId: DEFAULT_PAGE_ID, @@ -161,6 +167,11 @@ export const useCanvasStore = create((set, get) => ({ const { layerPanelOpen, variablesPanelOpen } = get() persistPrefs({ layerPanelOpen, variablesPanelOpen, codePanelOpen: open }) }, + setRightPanelTab: (tab) => { + set({ rightPanelTab: tab }) + const { layerPanelOpen, variablesPanelOpen, codePanelOpen } = get() + persistPrefs({ layerPanelOpen, variablesPanelOpen, codePanelOpen, rightPanelTab: tab }) + }, setFigmaImportDialogOpen: (open) => set({ figmaImportDialogOpen: open }), setActivePageId: (activePageId) => set({ activePageId }), @@ -172,6 +183,7 @@ export const useCanvasStore = create((set, get) => ({ if (typeof data.layerPanelOpen === 'boolean') set({ layerPanelOpen: data.layerPanelOpen }) if (typeof data.variablesPanelOpen === 'boolean') set({ variablesPanelOpen: data.variablesPanelOpen }) if (typeof data.codePanelOpen === 'boolean') set({ codePanelOpen: data.codePanelOpen }) + if (data.rightPanelTab === 'design' || data.rightPanelTab === 'code') set({ rightPanelTab: data.rightPanelTab }) } catch { /* ignore */ } }, })) diff --git a/src/stores/document-store.ts b/src/stores/document-store.ts index 20919753..9c74bfc8 100644 --- a/src/stores/document-store.ts +++ b/src/stores/document-store.ts @@ -687,12 +687,20 @@ export const useDocumentStore = create( // Push current state to history so MCP changes are undoable useHistoryStore.getState().pushState(get().document) const migrated = migrateToPages(doc) - set({ document: migrated, isDirty: true }) // Preserve activePageId if page still exists const activePageId = useCanvasStore.getState().activePageId const pageExists = migrated.pages?.some((p) => p.id === activePageId) - if (!pageExists && migrated.pages && migrated.pages.length > 0) { - useCanvasStore.getState().setActivePageId(migrated.pages[0].id) + const targetPageId = pageExists + ? activePageId + : migrated.pages?.[0]?.id + // Force new children reference so canvas sync subscriber always detects the change + if (targetPageId && migrated.pages) { + const page = migrated.pages.find((p) => p.id === targetPageId) + if (page) page.children = [...page.children] + } + set({ document: migrated, isDirty: true }) + if (!pageExists && targetPageId) { + useCanvasStore.getState().setActivePageId(targetPageId) } }, diff --git a/src/styles.css b/src/styles.css index bbda019c..aa4a5a01 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,5 +1,12 @@ @import "tailwindcss"; +@utility scrollbar-none { + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index eaa36fdd..1374024d 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -26,7 +26,10 @@ interface ElectronAPI { ) => Promise saveToPath: (filePath: string, content: string) => Promise onMenuAction: (callback: (action: string) => void) => () => void - setTheme: (theme: 'dark' | 'light') => void + onOpenFile: (callback: (filePath: string) => void) => () => void + readFile: (filePath: string) => Promise<{ filePath: string; content: string } | null> + getPendingFile: () => Promise + setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void updater: { getState: () => Promise checkForUpdates: () => Promise diff --git a/src/utils/__tests__/boolean-ops.test.ts b/src/utils/__tests__/boolean-ops.test.ts new file mode 100644 index 00000000..3e21bffb --- /dev/null +++ b/src/utils/__tests__/boolean-ops.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest' +import { + canBooleanOp, + executeBooleanOp, +} from '../boolean-ops' +import type { PenNode, RectangleNode, EllipseNode, PathNode, PolygonNode } from '@/types/pen' + +function makeRect( + id: string, + x: number, + y: number, + w: number, + h: number, +): RectangleNode { + return { + id, + type: 'rectangle', + name: `Rect ${id}`, + x, + y, + width: w, + height: h, + fill: [{ type: 'solid', color: '#ff0000' }], + } +} + +function makeEllipse( + id: string, + x: number, + y: number, + w: number, + h: number, +): EllipseNode { + return { id, type: 'ellipse', name: `Ellipse ${id}`, x, y, width: w, height: h } +} + +function makePolygon( + id: string, + x: number, + y: number, + w: number, + h: number, + count = 6, +): PolygonNode { + return { id, type: 'polygon', name: `Polygon ${id}`, x, y, width: w, height: h, polygonCount: count } +} + +function makePath(id: string, d: string, x = 0, y = 0): PathNode { + return { id, type: 'path', name: `Path ${id}`, d, x, y } +} + +describe('canBooleanOp', () => { + it('returns false for fewer than 2 nodes', () => { + expect(canBooleanOp([])).toBe(false) + expect(canBooleanOp([makeRect('a', 0, 0, 50, 50)])).toBe(false) + }) + + it('returns true for 2+ shape nodes', () => { + expect( + canBooleanOp([makeRect('a', 0, 0, 50, 50), makeRect('b', 25, 25, 50, 50)]), + ).toBe(true) + }) + + it('returns true for mixed shape types', () => { + expect( + canBooleanOp([makeRect('a', 0, 0, 50, 50), makeEllipse('b', 25, 25, 50, 50)]), + ).toBe(true) + }) + + it('returns false if text or image nodes are included', () => { + const textNode: PenNode = { id: 't', type: 'text', content: 'hi' } + expect(canBooleanOp([makeRect('a', 0, 0, 50, 50), textNode])).toBe(false) + }) +}) + +describe('executeBooleanOp', () => { + it('performs union of two overlapping rectangles', () => { + const r1 = makeRect('a', 0, 0, 100, 100) + const r2 = makeRect('b', 50, 50, 100, 100) + const result = executeBooleanOp([r1, r2], 'union') + expect(result).not.toBeNull() + expect(result!.type).toBe('path') + expect(result!.d).toBeTruthy() + expect(result!.name).toBe('Union') + expect(result!.x).toBeCloseTo(0, 0) + expect(result!.y).toBeCloseTo(0, 0) + // Union should be larger than either original + expect(result!.width).toBeGreaterThanOrEqual(149) + expect(result!.height).toBeGreaterThanOrEqual(149) + }) + + it('performs subtract of two overlapping rectangles', () => { + const r1 = makeRect('a', 0, 0, 100, 100) + const r2 = makeRect('b', 50, 50, 100, 100) + const result = executeBooleanOp([r1, r2], 'subtract') + expect(result).not.toBeNull() + expect(result!.type).toBe('path') + expect(result!.name).toBe('Subtract') + }) + + it('performs intersect of two overlapping rectangles', () => { + const r1 = makeRect('a', 0, 0, 100, 100) + const r2 = makeRect('b', 50, 50, 100, 100) + const result = executeBooleanOp([r1, r2], 'intersect') + expect(result).not.toBeNull() + expect(result!.type).toBe('path') + expect(result!.name).toBe('Intersect') + // Intersection should be 50x50 area + expect(result!.width).toBeCloseTo(50, 0) + expect(result!.height).toBeCloseTo(50, 0) + }) + + it('preserves fill from first operand', () => { + const r1 = makeRect('a', 0, 0, 100, 100) + const r2 = makeRect('b', 50, 50, 100, 100) + const result = executeBooleanOp([r1, r2], 'union') + expect(result!.fill).toEqual([{ type: 'solid', color: '#ff0000' }]) + }) + + it('handles ellipse + rectangle boolean', () => { + const e = makeEllipse('a', 0, 0, 100, 100) + const r = makeRect('b', 25, 25, 50, 50) + const result = executeBooleanOp([e, r], 'subtract') + expect(result).not.toBeNull() + expect(result!.d).toBeTruthy() + }) + + it('handles polygon + rectangle boolean', () => { + const p = makePolygon('a', 0, 0, 100, 100, 6) + const r = makeRect('b', 25, 25, 50, 50) + const result = executeBooleanOp([p, r], 'intersect') + expect(result).not.toBeNull() + }) + + it('handles path + path boolean', () => { + const p1 = makePath('a', 'M 0 0 L 100 0 L 100 100 L 0 100 Z') + const p2 = makePath('b', 'M 50 50 L 150 50 L 150 150 L 50 150 Z') + const result = executeBooleanOp([p1, p2], 'union') + expect(result).not.toBeNull() + }) + + it('handles 3+ nodes (fold left)', () => { + const r1 = makeRect('a', 0, 0, 100, 100) + const r2 = makeRect('b', 50, 0, 100, 100) + const r3 = makeRect('c', 100, 0, 100, 100) + const result = executeBooleanOp([r1, r2, r3], 'union') + expect(result).not.toBeNull() + expect(result!.width).toBeCloseTo(200, 0) + expect(result!.height).toBeCloseTo(100, 0) + }) + + it('returns null for non-overlapping intersect', () => { + const r1 = makeRect('a', 0, 0, 50, 50) + const r2 = makeRect('b', 200, 200, 50, 50) + const result = executeBooleanOp([r1, r2], 'intersect') + // Non-overlapping: either null or empty path + if (result) { + expect(result.width).toBeLessThan(1) + } + }) + + it('handles rotated shapes', () => { + const r1: RectangleNode = { ...makeRect('a', 50, 50, 100, 100), rotation: 45 } + const r2 = makeRect('b', 50, 50, 100, 100) + const result = executeBooleanOp([r1, r2], 'intersect') + expect(result).not.toBeNull() + }) +}) diff --git a/src/utils/boolean-ops.ts b/src/utils/boolean-ops.ts new file mode 100644 index 00000000..be936fe4 --- /dev/null +++ b/src/utils/boolean-ops.ts @@ -0,0 +1,243 @@ +import paper from 'paper' +import { nanoid } from 'nanoid' +import type { PenNode, PathNode } from '@/types/pen' + +export type BooleanOpType = 'union' | 'subtract' | 'intersect' + +// --------------------------------------------------------------------------- +// Paper.js scope — headless (no canvas needed) +// --------------------------------------------------------------------------- + +let scope: paper.PaperScope | null = null + +function getScope(): paper.PaperScope { + if (!scope) { + scope = new paper.PaperScope() + scope.setup(new scope.Size(1, 1)) + } + scope.activate() + return scope +} + +// --------------------------------------------------------------------------- +// Shape → SVG path string conversion +// --------------------------------------------------------------------------- + +function sizeVal(v: number | string | undefined, fallback: number): number { + if (typeof v === 'number') return v + if (typeof v === 'string') { + const m = v.match(/\((\d+(?:\.\d+)?)\)/) + if (m) return parseFloat(m[1]) + const n = parseFloat(v) + if (!isNaN(n)) return n + } + return fallback +} + +function rectToPath( + w: number, + h: number, + cr?: number | [number, number, number, number], +): string { + if (!cr || (typeof cr === 'number' && cr === 0)) { + return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z` + } + let [tl, tr, br, bl] = + typeof cr === 'number' ? [cr, cr, cr, cr] : cr + const maxR = Math.min(w, h) / 2 + tl = Math.min(tl, maxR) + tr = Math.min(tr, maxR) + br = Math.min(br, maxR) + bl = Math.min(bl, maxR) + return [ + `M ${tl} 0`, + `L ${w - tr} 0`, + tr > 0 ? `A ${tr} ${tr} 0 0 1 ${w} ${tr}` : '', + `L ${w} ${h - br}`, + br > 0 ? `A ${br} ${br} 0 0 1 ${w - br} ${h}` : '', + `L ${bl} ${h}`, + bl > 0 ? `A ${bl} ${bl} 0 0 1 0 ${h - bl}` : '', + `L 0 ${tl}`, + tl > 0 ? `A ${tl} ${tl} 0 0 1 ${tl} 0` : '', + 'Z', + ] + .filter(Boolean) + .join(' ') +} + +function ellipseToPath(rx: number, ry: number): string { + // 4-arc approximation of an ellipse centered at (rx, ry) + return [ + `M ${rx * 2} ${ry}`, + `A ${rx} ${ry} 0 0 1 ${rx} ${ry * 2}`, + `A ${rx} ${ry} 0 0 1 0 ${ry}`, + `A ${rx} ${ry} 0 0 1 ${rx} 0`, + `A ${rx} ${ry} 0 0 1 ${rx * 2} ${ry}`, + 'Z', + ].join(' ') +} + +function polygonToPath(count: number, w: number, h: number): string { + const parts: string[] = [] + 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 + parts.push(i === 0 ? `M ${px} ${py}` : `L ${px} ${py}`) + } + parts.push('Z') + return parts.join(' ') +} + +/** Convert a shape node to an SVG path `d` string in local coordinates (origin at 0,0). */ +function nodeToLocalPath(node: PenNode): string | null { + switch (node.type) { + case 'rectangle': + case 'frame': { + const w = sizeVal(node.width, 100) + const h = sizeVal(node.height, 100) + return rectToPath(w, h, node.cornerRadius) + } + case 'ellipse': { + const w = sizeVal(node.width, 100) + const h = sizeVal(node.height, 100) + return ellipseToPath(w / 2, h / 2) + } + case 'polygon': { + const w = sizeVal(node.width, 100) + const h = sizeVal(node.height, 100) + return polygonToPath(node.polygonCount || 6, w, h) + } + case 'path': + return node.d + case 'line': + return `M 0 0 L ${(node.x2 ?? (node.x ?? 0) + 100) - (node.x ?? 0)} ${(node.y2 ?? (node.y ?? 0)) - (node.y ?? 0)}` + default: + return null + } +} + +// --------------------------------------------------------------------------- +// Boolean operation helpers +// --------------------------------------------------------------------------- + +/** Types that can participate in boolean operations. */ +const BOOLEAN_TYPES = new Set([ + 'rectangle', + 'ellipse', + 'polygon', + 'path', + 'line', + 'frame', +]) + +export function canBooleanOp(nodes: PenNode[]): boolean { + if (nodes.length < 2) return false + return nodes.every((n) => BOOLEAN_TYPES.has(n.type)) +} + +/** + * Create a Paper.js PathItem from a PenNode, positioned in absolute scene + * coordinates (applying x, y, rotation). + */ +function nodeToPaperPath(node: PenNode): paper.PathItem | null { + const d = nodeToLocalPath(node) + if (!d) return null + + const s = getScope() + let item: paper.PathItem + try { + item = s.CompoundPath.create(d) + } catch { + return null + } + + // Apply node transform: translate to (x, y), then rotate around center + const x = node.x ?? 0 + const y = node.y ?? 0 + item.translate(new s.Point(x, y)) + + const rotation = node.rotation ?? 0 + if (rotation !== 0) { + // Rotate around the bounding-box center of the translated item + item.rotate(rotation, item.bounds.center) + } + + return item +} + +/** + * Execute a boolean operation on the given PenNodes. + * Returns a new PathNode with the result, or null on failure. + */ +export function executeBooleanOp( + nodes: PenNode[], + operation: BooleanOpType, +): PathNode | null { + if (nodes.length < 2) return null + + const paperPaths = nodes.map(nodeToPaperPath) + if (paperPaths.some((p) => p === null)) return null + + const paths = paperPaths as paper.PathItem[] + + // Accumulate: fold left with the boolean operation + let result = paths[0] + for (let i = 1; i < paths.length; i++) { + switch (operation) { + case 'union': + result = result.unite(paths[i]) + break + case 'subtract': + result = result.subtract(paths[i]) + break + case 'intersect': + result = result.intersect(paths[i]) + break + } + } + + // Extract SVG path data + const pathData = result.pathData + if (!pathData || pathData.trim().length === 0) return null + + // Get bounding box for positioning + const bounds = result.bounds + + // Translate path so it starts at origin (0,0) + result.translate(new paper.Point(-bounds.x, -bounds.y)) + const originPathData = result.pathData + + // Clean up Paper.js items + for (const p of paths) p.remove() + result.remove() + + // Build the label + const opLabels: Record = { + union: 'Union', + subtract: 'Subtract', + intersect: 'Intersect', + } + + // Inherit style from first operand + const first = nodes[0] + const fill = 'fill' in first ? first.fill : undefined + const stroke = 'stroke' in first ? first.stroke : undefined + const effects = 'effects' in first ? first.effects : undefined + const opacity = first.opacity + + return { + id: nanoid(), + type: 'path', + name: opLabels[operation], + d: originPathData, + x: bounds.x, + y: bounds.y, + width: Math.round(bounds.width * 100) / 100, + height: Math.round(bounds.height * 100) / 100, + fill, + stroke, + effects, + opacity, + } +} diff --git a/src/utils/syntax-highlight.ts b/src/utils/syntax-highlight.ts index 0f40a4aa..e9c474bb 100644 --- a/src/utils/syntax-highlight.ts +++ b/src/utils/syntax-highlight.ts @@ -123,10 +123,54 @@ function renderTokens(code: string, tokens: Token[]): string { return result } -export type SyntaxLanguage = 'jsx' | 'html' | 'css' +const SWIFT_RULES: TokenRule[] = [ + { pattern: /\/\/.*/g, className: 'syn-comment' }, + { pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' }, + { pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' }, + { pattern: /\b(import|struct|var|let|func|return|if|else|for|in|while|switch|case|break|some|self|true|false|nil|class|enum|protocol|extension|guard|throw|try|catch|async|await|private|public|internal|static|mutating|typealias|associatedtype|where)\b/g, className: 'syn-keyword' }, + { pattern: /\b(View|Text|VStack|HStack|ZStack|Color|Font|Image|Rectangle|RoundedRectangle|Circle|Ellipse|Capsule|Path|Spacer|Divider|GeometryReader|ScrollView|Button|AsyncImage|LinearGradient|RadialGradient|Shape|some)\b/g, className: 'syn-tag' }, + { pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' }, + { pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' }, + { pattern: /[{}()]/g, className: 'syn-bracket' }, +] + +const KOTLIN_RULES: TokenRule[] = [ + { pattern: /\/\/.*/g, className: 'syn-comment' }, + { pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' }, + { pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' }, + { pattern: /\b(package|import|fun|val|var|return|if|else|for|in|while|when|is|class|object|interface|override|private|public|internal|companion|data|sealed|abstract|open|suspend|inline|annotation|true|false|null)\b/g, className: 'syn-keyword' }, + { pattern: /\b(Composable|Modifier|Column|Row|Box|Text|Image|Icon|Canvas|Divider|Spacer|Surface|Card|Scaffold|LazyColumn|LazyRow|Color|FontWeight|FontStyle|TextAlign|TextDecoration|Arrangement|Alignment|RoundedCornerShape|CircleShape|Dp|ContentScale)\b/g, className: 'syn-tag' }, + { pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' }, + { pattern: /\b\d+\.?\d*(f|dp|sp)?\b/g, className: 'syn-number' }, + { pattern: /@[A-Za-z]+/g, className: 'syn-attr' }, + { pattern: /[{}()]/g, className: 'syn-bracket' }, +] + +const DART_RULES: TokenRule[] = [ + { pattern: /\/\/.*/g, className: 'syn-comment' }, + { pattern: /\/\*[\s\S]*?\*\//g, className: 'syn-comment' }, + { pattern: /"(?:[^"\\]|\\.)*"/g, className: 'syn-string' }, + { pattern: /'(?:[^'\\]|\\.)*'/g, className: 'syn-string' }, + { pattern: /\b(import|class|extends|implements|with|mixin|abstract|final|const|var|void|return|if|else|for|in|while|switch|case|break|continue|new|this|super|true|false|null|async|await|static|late|required|override|enum|typedef|try|catch|throw|dynamic)\b/g, className: 'syn-keyword' }, + { pattern: /\b(Widget|StatelessWidget|StatefulWidget|State|BuildContext|Container|Column|Row|Stack|Positioned|Text|Image|SizedBox|Padding|Center|Expanded|Flexible|Scaffold|AppBar|Material|BoxDecoration|BorderRadius|Border|EdgeInsets|Color|TextStyle|FontWeight|FontStyle|TextAlign|BoxFit|MainAxisAlignment|CrossAxisAlignment|CustomPaint|ClipPath|Opacity|Transform|LinearGradient|RadialGradient)\b/g, className: 'syn-tag' }, + { pattern: /\.[a-zA-Z]+(?=\()/g, className: 'syn-attr' }, + { pattern: /@[a-zA-Z]+/g, className: 'syn-attr' }, + { pattern: /\b\d+\.?\d*\b/g, className: 'syn-number' }, + { pattern: /[{}()]/g, className: 'syn-bracket' }, +] + +export type SyntaxLanguage = 'jsx' | 'html' | 'css' | 'swift' | 'kotlin' | 'dart' export function highlightCode(code: string, language: SyntaxLanguage): string { - const rules = language === 'jsx' ? JSX_RULES : language === 'html' ? HTML_RULES : CSS_RULES + const ruleMap: Record = { + jsx: JSX_RULES, + html: HTML_RULES, + css: CSS_RULES, + swift: SWIFT_RULES, + kotlin: KOTLIN_RULES, + dart: DART_RULES, + } + const rules = ruleMap[language] const tokens = tokenize(code, rules) return renderTokens(code, tokens) } diff --git a/vite.config.ts b/vite.config.ts index 1b545088..2b5b2eea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,7 +21,7 @@ const config = defineConfig({ plugins: [ devtools(), nitro({ - rollupConfig: { external: [/^@sentry\//] }, + rollupConfig: { external: [/^@sentry\//, 'canvas', 'jsdom', 'cssstyle'] }, serverDir: './server', ...(isElectronBuild ? { preset: 'node-server' } : {}), }),