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 @@
-
+
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 - +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 - +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é - +त्वरित शुरुआत · 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 पैकेज ## समुदाय - +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 - +クイックスタート · 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 パッケージング ## コミュニティ - +빠른 시작 · 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 패키징 ## 커뮤니티 - +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 - +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 - +Быстрый старт · 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 ## Сообщество - +เริ่มต้นอย่างรวดเร็ว · 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 ## ชุมชน - +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 - +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 - +快速開始 · 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 封裝 ## 社群 - +快速开始 · 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 打包 ## 社区 - +
+
## 许可证
[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++{/* Footer info */} -- - {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 ( -+ + {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')}++ {/* 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 */} -++ > + ) + + if (embedded) return content + + return ( +{(nodeIsReusable || nodeIsInstance) && ()} @@ -364,6 +364,14 @@ export default function PropertyPanel() { + {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 */} ++ ) +} 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 }) {+ {tabs.map((tab) => ( + + ))} ++ + {/* Content */} + {activeTab === 'design' ? ( ++ ) : ( + + )} + -{/* 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 */} - +{/* Scrollable content */} -{t('agents.title')}
@@ -435,10 +440,10 @@ export default function AgentSettingsDialog() {+{/* Agents section */} --++{/* Divider */} - + {/* MCP Server section */} -{t('agents.agentsOnCanvas')} @@ -453,17 +458,17 @@ export default function AgentSettingsDialog() {
-++-{t('agents.mcpServer')}
+{/* Status indicator */}{mcpServerRunning && mcpServerLocalIp && ( -- {t('agents.mcpLanAccess')} -{/* Divider */} - + {/* MCP integrations section */}- http://{mcpServerLocalIp}:{mcpHttpPort}/mcp -- ++)} {mcpServerError && ( @@ -531,23 +536,23 @@ export default function AgentSettingsDialog() {+ {t('agents.mcpClientConfig')} + ++{`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}-+-{t('agents.mcpIntegrations')}
+{mcpIntegrations.map((m) => (@@ -587,7 +592,7 @@ export default function AgentSettingsDialog() { {/* Auto-update toggle (Electron only) */} {isElectron && ( <> - +@@ -579,7 +584,7 @@ export default function AgentSettingsDialog() {)} -{mcpError}
+
{t('agents.mcpRestart')}
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))}${tag}>` + } + + 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}` + } + 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}` + } + + 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 ` + + +${template} + + + +` +} + +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' } : {}), }),