mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.3.0 (#24)
* feat(boolean-operations): implement boolean operations in the editor - Added a new BooleanToolbar component for union, subtract, and intersect operations. - Integrated boolean operations into the layer context menu and keyboard shortcuts. - Enhanced the editor layout to include the boolean toolbar for improved user interaction. - Updated internationalization support with new translation keys for boolean operations. - Bumped version to 0.3.0 to reflect the addition of these features. * refactor(editor): update editor layout and panels for improved functionality - Replaced the PropertyPanel with a new RightPanel that includes both Property and Code panels. - Removed the CodePanel from the main editor layout and integrated it into the RightPanel. - Updated keyboard shortcuts to switch the right panel to the code tab. - Enhanced the LayerPanel with a resizable width feature for better user experience. - Added internationalization support for new right panel labels and code panel features. - Introduced new code generation capabilities for various frameworks in the CodePanel. - Improved overall layout structure for better responsiveness and usability. * feat(electron): implement .op file association and enhance file handling - Added support for .op file association in electron-builder, allowing OpenPencil documents to be opened directly from the file system. - Implemented IPC handlers for opening and reading .op files, ensuring proper loading of document content. - Enhanced the main process to handle file opening events on macOS and single-instance locking on Windows/Linux. - Updated the renderer to listen for file open events and load documents accordingly. - Improved README to reflect new file association feature. * fix(canvas): improve layout accuracy for AI-generated designs - Unify lineHeight default via canonical defaultLineHeight() function - Unify text measurement by removing duplicate estimators in generation-utils - Fix optical centering formula to scale proportionally with fontSize - Round layout positions to whole pixels to prevent sub-pixel artifacts - Recursively sanitize nested x/y in streaming layout containers - Fix input trailing icon alignment using fill_container instead of space_between * feat(canvas): right-align agent badge and add breathing glow border - Agent badge now right-aligned to frame's right edge instead of after label - Added breathing glow border around agent-owned frames during generation - Glow border uses same color and lifecycle as the agent badge - Removed unused BADGE_GAP constant and useDocumentStore import * feat(code-panel): enhance tab scrolling functionality and add scrollbar utility - Introduced left and right scroll buttons for tab navigation in the CodePanel, improving user experience for navigating long tab lists. - Added a custom utility to hide scrollbars for a cleaner interface. - Updated styles for better responsiveness and usability in the CodePanel layout. * fix(docs): update Discord invite links in multiple README files - Replaced outdated Discord invite links with the new link across all language-specific README files. - Ensured consistency in the documentation for community engagement. * feat(code-panel): enhance system prompt for responsive design - Updated the ENHANCE_SYSTEM_PROMPT to emphasize the importance of responsive design in code rewriting. - Added detailed guidelines for converting fixed pixel widths to relative units and using responsive Tailwind breakpoints. - Ensured that the output remains visually faithful on desktop while adapting gracefully across screen sizes. * feat(docs): add WeChat group information to README.zh.md and include group image - Introduced a new section in the Chinese README to provide details about the WeChat group for community engagement. - Added an image representing the WeChat group for better visibility and user interaction. * feat(electron): enhance theme management and title bar overlay for Windows/Linux - Updated the `setTheme` method in the Electron API to accept custom colors for the title bar overlay, improving theme synchronization across platforms. - Adjusted title bar overlay colors for Windows and Linux to ensure proper visibility and aesthetics. - Enhanced the top bar component to read computed CSS colors and apply them dynamically, ensuring a consistent user interface. - Improved handling of theme changes in the application to support background and foreground color customization. * fix(screenshot): update screenshot image for improved clarity and quality * fix(docs): update WeChat group image path in README.zh.md for consistency * fix(ai): fix post-generation validation pipeline and text centering - Fix Agent SDK validation: save temp screenshots inside project dir (.openpencil-tmp/) so Claude Code plan mode can read them, instead of /tmp/ which is outside the project sandbox - Enrich validation tree dump with fill colors, stroke, fontSize, fontWeight, textAlign, cornerRadius, opacity for comprehensive visual analysis - Add multi-round validation with quality scoring (threshold 8/10), 500ms stabilization delay between rounds - Add detailed debug logging to applyValidationFixes showing which nodes were found/skipped and property changes - Fix canvas sync needsTextbox check to also account for textAlign (matching isFixedWidthText in factory), preventing IText↔Textbox thrashing on every sync tick - Auto-center text in vertical+center layouts by expanding to full container width and injecting textAlign:'center' - Force Textbox for non-left-aligned text so textAlign is respected (IText ignores width and computes its own) * fix(canvas): use precise text width estimation for fit-content layout Remove the 14% safety factor from text width estimation when computing fit-content/natural-width text dimensions. IText auto-computes its own width and ignores our setting, so the safety margin only inflated the layout allocation, making text appear left-shifted within its container. * fix(canvas): center fit-content text in horizontal layouts For text nodes with fit-content width in horizontal layouts, set textAlign:'center' to compensate for width estimation inaccuracy. The estimated box is typically wider than the actual rendered text, causing left-aligned text to appear visually shifted. Centering distributes the estimation error evenly on both sides. * feat(ai): show validation details in checklist panel - Accumulate validation log (screenshot, analysis, fixes) instead of overwriting status messages, so the full process is visible - Preserve step thinking content in buildFinalStepTags (was discarded) - Add details field to pipeline items and render in checklist UI - Each validation step now shows: screenshot captured, issues found, quality score, fixes applied * feat(ai): add visual reference pipeline types and integration hooks - Add DesignSystem and VisualReference types to ai-types - Add 'visual-ref' mode to AIDesignRequest and SubTask.htmlReference - Detect visual-ref candidates in chat handlers (landing pages, websites) - Wire visual-ref mode in design-generator and orchestrator - Inject HTML reference snippets into sub-agent prompts * feat(ai): add modular design principles for sub-agent context - Add design-principles module with topic files: color, typography, spacing, composition, components - Selectively load relevant principles based on prompt content - Inject design principles into sub-agent system prompts * feat(ai): implement visual reference pipeline - Add design-system-generator: generates color/typography/spacing tokens - Add design-code-generator: generates HTML/CSS from design system - Add html-renderer: renders HTML to screenshot via html2canvas - Add visual-ref-orchestrator: coordinates the full pipeline (design system → HTML code → screenshot → enrich subtasks) - Add html2canvas dependency for client-side HTML rendering * feat(mcp): default filePath to live canvas and fix cross-platform issues - Default all MCP tool filePath to live://canvas when omitted, so tools operate on the real-time canvas instead of stale files - Remove filePath from required params in all tool schemas (21 interfaces) - Fix mcp-server-manager.ts using process.cwd() which fails in Electron production on Linux — now checks ELECTRON_RESOURCES_PATH first - Fix stopMcpHttpServer using SIGTERM on Windows — use taskkill instead - Force new children reference in applyExternalDocument to ensure canvas sync subscriber always detects MCP-pushed document updates * feat(mcp): enhance design prompt with semantic roles, CJK typography, and layout rules Add comprehensive design knowledge to MCP design prompt for better AI-generated designs: design type detection (mobile vs desktop), full semantic role reference with context-aware defaults, CJK typography rules, expanded text/layout/form guidelines, and detailed post-processing documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(ai): implement intent classification for chat handlers - Replace hardcoded keyword matching with a lightweight LLM call to classify user intent in chat messages. - Introduce a new function `classifyIntent` to determine if the request is for design generation or conversation. - Update design request handling in `useChatHandlers` to utilize the new classification method. - Enhance design prompt documentation to reflect changes in design type detection based on intent rather than keywords. * fix(ai): handle string qualityScore in validation response parsing The LLM sometimes returns qualityScore as a string (e.g. "8" instead of 8), causing it to fall through to 0. Also hide misleading "quality: 0/10" display when the score couldn't be determined, and log raw response for debugging. * fix(ai): increase validation timeout to 90s and fix quality score parsing Agent SDK validation requires spawning a process, reading the image, and analyzing it — 30s was consistently timing out. Also handle string qualityScore values from LLM responses and hide misleading 0/10 display. * fix(ai): fix validation timeout and response parsing - Increase validation timeout from 30s to 180s (Agent SDK needs time for subprocess spawn + OAuth auth + multi-turn image reading) - Strip <tool_use> XML blocks from Agent SDK response before extracting JSON — the tool call XML was confusing the regex, causing qualityScore to parse as 0 despite valid JSON being present - Handle string qualityScore values and hide misleading "quality: 0/10" - Revert unnecessary direct API key approach for validation * fix(ai): prevent node ID collisions between generations When generating new content on a canvas with existing nodes, AI-generated IDs (e.g. brand-spacer) would collide with previous generations. Now captures pre-existing node IDs at generation start and checks against them during upsert sanitization. Remapped IDs are tracked in generationRemappedIds so progressive streaming updates can still find their nodes. * fix(ai): require styleGuide in orchestrator plan and fix validation detail icons - Add fallback default styleGuide when orchestrator LLM omits it - Strengthen prompt to mark styleGuide as REQUIRED - Replace emoji icons in validation details with [done]/[pending]/[error] markers for consistent styling with the checklist design system * feat(server): add port file plugin for server instance discovery - Introduce a new Nitro plugin that writes a port file on server startup to allow the MCP server to discover the running instance, whether it's a development server or Electron. - Implement error handling in the Electron main process for writing the port file, logging any failures. - Update Vite configuration to include additional external dependencies in the rollup configuration. * feat(electron): implement IPC for retrieving pending file paths - Added a new IPC handler `file:getPending` to retrieve and clear the pending file path when the React app mounts. - Updated the Electron API to include `getPendingFile` for renderer access. - Enhanced the `useElectronMenu` hook to load any pending file on application startup. - Updated UI components to reflect changes in file handling and improved user experience. * fix(panels): replace emoji icons with styled icons in validation checklist - Parse [done]/[pending]/[error] prefixes in detail lines and render as styled circle icons matching the parent checklist design system - Replace remaining emoji markers in design-validation.ts with text prefixes - Fix isApplied detection to recognize new [done] Applied marker * refactor(electron): update settings path to use platform-standard app data directory - Changed the settings file path to utilize Electron's user data directory for better cross-platform compatibility. - Updated the settings writing function to ensure the user data directory is created if it doesn't exist. - Added comments to clarify the storage location for different operating systems. - Implemented a fixed partition for localStorage/cookies to maintain data across server port changes. * feat(ai): enhance validation with pre-checks, structural fixes, and border detection - Add design-pre-validation.ts: pure code checks before LLM validation - Invisible container detection (same fill as parent → auto-add border) - Sibling consistency (majority-rule for height/cornerRadius) - Add structural fixes to validation: addChild/removeNode operations - Icon injection via lookupIconByName with server fallback - autoFixParentLayout with child count guard to prevent layout breakage - Add strokeColor/strokeWidth to safe fix properties for border fixes - Simplify intent classification: all design requests use visual-ref pipeline - Fix checklist: "Found N issues" now shows [done] instead of [pending] - Fix qualityScore: only update when > 0 to preserve valid round scores * fix(ai): cherry-pick safe validation improvements, drop aggressive pre-checks Keep: stroke tree dump bug fix (object not array), qualityScore=0 false positive detection, fit_content→fixed safety guard, empty path removal, type-specific sibling consistency, repeated fix filtering, screenshot extraction to design-screenshot.ts. Drop: detectForcedFixedHeight (destroyed input/button heights), MAX_VALIDATION_ROUNDS 5 (too many rounds), removal of quality threshold early stop, section regeneration phase. --------- Co-authored-by: Fini <fini.yang@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5e13f48be1
commit
ca1b5370ae
118 changed files with 7680 additions and 617 deletions
17
CLAUDE.md
17
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.
|
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
|
### 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`)
|
- `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`)
|
- `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
|
- `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):
|
- **`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
|
- `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
|
- `property-panel.tsx` — Unified property panel
|
||||||
|
|
@ -194,8 +194,8 @@ PenDocument (source of truth)
|
||||||
- `figma-image-resolver.ts` — Resolves image blob references
|
- `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/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
|
||||||
- **`src/hooks/`** — Hooks (2 files):
|
- **`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-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
|
- `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/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging)
|
||||||
- **`src/uikit/`** — UI kit system (3 files + `kits/` subdir):
|
- **`src/uikit/`** — UI kit system (3 files + `kits/` subdir):
|
||||||
- `built-in-registry.ts` — Default built-in UIKit with standard UI components
|
- `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
|
- `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)
|
- `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`)
|
- `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/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):
|
- **`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`)
|
- `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 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/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)
|
- **`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)
|
- **`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
|
- **`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`
|
- 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`
|
- 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
|
- 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
|
### CI / CD
|
||||||
|
|
||||||
|
|
|
||||||
11
README.de.md
11
README.de.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#schnellstart">Schnellstart</a> ·
|
<a href="#schnellstart">Schnellstart</a> ·
|
||||||
<a href="#ki-natives-design">KI</a> ·
|
<a href="#ki-natives-design">KI</a> ·
|
||||||
<a href="#funktionen">Funktionen</a> ·
|
<a href="#funktionen">Funktionen</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#mitwirken">Mitwirken</a>
|
<a href="#mitwirken">Mitwirken</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil wurde von Grund auf mit KI im Kern aufgebaut — nicht als Plugin, so
|
||||||
**Canvas und Zeichnen**
|
**Canvas und Zeichnen**
|
||||||
- Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten
|
- Unendliche Canvas mit Pan, Zoom, intelligenten Ausrichtungshilfslinien und Einrasten
|
||||||
- Rechteck, Ellipse, Linie, Polygon, Stift (Bezier), Frame, Text
|
- 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)
|
- Icon-Auswahl (Iconify) und Bildimport (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-Layout — vertikal/horizontal mit Gap, Padding, Justify, Align
|
- Auto-Layout — vertikal/horizontal mit Gap, Padding, Justify, Align
|
||||||
- Mehrseitige Dokumente mit Tab-Navigation
|
- Mehrseitige Dokumente mit Tab-Navigation
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel |
|
| `Del` | Löschen | | `Cmd+Shift+V` | Variablen-Panel |
|
||||||
| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat |
|
| `[ / ]` | Reihenfolge ändern | | `Cmd+J` | KI-Chat |
|
||||||
| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen |
|
| Pfeiltasten | 1px verschieben | | `Cmd+,` | Agenteneinstellungen |
|
||||||
|
| `Cmd+Alt+U` | Boolesche Vereinigung | | `Cmd+Alt+S` | Boolesche Subtraktion |
|
||||||
|
| `Cmd+Alt+I` | Boolesche Schnittmenge | | | |
|
||||||
|
|
||||||
## Skripte
|
## Skripte
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
|
||||||
- [x] MCP-Server-Integration
|
- [x] MCP-Server-Integration
|
||||||
- [x] Mehrseitige Unterstützung
|
- [x] Mehrseitige Unterstützung
|
||||||
- [x] Figma-`.fig`-Import
|
- [x] Figma-`.fig`-Import
|
||||||
- [ ] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge)
|
- [x] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge)
|
||||||
- [ ] Kollaboratives Bearbeiten
|
- [ ] Kollaboratives Bearbeiten
|
||||||
- [ ] Plugin-System
|
- [ ] Plugin-System
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Unserem Discord beitreten</strong>
|
<strong> Unserem Discord beitreten</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.es.md
11
README.es.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#inicio-rápido">Inicio Rápido</a> ·
|
<a href="#inicio-rápido">Inicio Rápido</a> ·
|
||||||
<a href="#diseño-nativo-de-ia">IA</a> ·
|
<a href="#diseño-nativo-de-ia">IA</a> ·
|
||||||
<a href="#características">Características</a> ·
|
<a href="#características">Características</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contribuir">Contribuir</a>
|
<a href="#contribuir">Contribuir</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil está construido desde cero con IA en su núcleo — no como un plugi
|
||||||
**Lienzo y Dibujo**
|
**Lienzo y Dibujo**
|
||||||
- Lienzo infinito con panorámica, zoom, guías de alineación inteligentes y ajuste
|
- 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
|
- 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)
|
- 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
|
- Diseño automático — vertical/horizontal con gap, padding, justify, align
|
||||||
- Documentos multipágina con navegación por pestañas
|
- Documentos multipágina con navegación por pestañas
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables |
|
| `Del` | Eliminar | | `Cmd+Shift+V` | Panel de variables |
|
||||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA |
|
| `[ / ]` | Reordenar | | `Cmd+J` | Chat de IA |
|
||||||
| Flechas | Mover 1px | | `Cmd+,` | Configuración de agente |
|
| 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
|
## Scripts
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Empaquetado de Electron
|
||||||
- [x] Integración con servidor MCP
|
- [x] Integración con servidor MCP
|
||||||
- [x] Soporte multipágina
|
- [x] Soporte multipágina
|
||||||
- [x] Importación de Figma `.fig`
|
- [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
|
- [ ] Edición colaborativa
|
||||||
- [ ] Sistema de plugins
|
- [ ] Sistema de plugins
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Empaquetado de Electron
|
||||||
|
|
||||||
## Comunidad
|
## Comunidad
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Únete a nuestro Discord</strong>
|
<strong> Únete a nuestro Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.fr.md
11
README.fr.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Démarrage rapide</a> ·
|
<a href="#quick-start">Démarrage rapide</a> ·
|
||||||
<a href="#ai-native-design">IA</a> ·
|
<a href="#ai-native-design">IA</a> ·
|
||||||
<a href="#features">Fonctionnalités</a> ·
|
<a href="#features">Fonctionnalités</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">Contribuer</a>
|
<a href="#contributing">Contribuer</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -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 et dessin**
|
||||||
- Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme
|
- Canevas infini avec panoramique, zoom, guides d'alignement intelligents et magnétisme
|
||||||
- Rectangle, Ellipse, Ligne, Polygone, Plume (Bézier), Frame, Texte
|
- 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)
|
- Sélecteur d'icônes (Iconify) et import d'images (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-layout — vertical/horizontal avec gap, padding, justify, align
|
- Auto-layout — vertical/horizontal avec gap, padding, justify, align
|
||||||
- Documents multi-pages avec navigation par onglets
|
- Documents multi-pages avec navigation par onglets
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables |
|
| `Del` | Supprimer | | `Cmd+Shift+V` | Panneau des variables |
|
||||||
| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA |
|
| `[ / ]` | Réordonner | | `Cmd+J` | Chat IA |
|
||||||
| Flèches | Déplacer de 1px | | `Cmd+,` | Paramètres de l'agent |
|
| 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
|
## Scripts
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
|
||||||
- [x] Intégration du serveur MCP
|
- [x] Intégration du serveur MCP
|
||||||
- [x] Support multi-pages
|
- [x] Support multi-pages
|
||||||
- [x] Import Figma `.fig`
|
- [x] Import Figma `.fig`
|
||||||
- [ ] Opérations booléennes (union, soustraction, intersection)
|
- [x] Opérations booléennes (union, soustraction, intersection)
|
||||||
- [ ] Édition collaborative
|
- [ ] Édition collaborative
|
||||||
- [ ] Système de plugins
|
- [ ] Système de plugins
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour
|
||||||
|
|
||||||
## Communauté
|
## Communauté
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Rejoindre notre Discord</strong>
|
<strong> Rejoindre notre Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.hi.md
11
README.hi.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">त्वरित शुरुआत</a> ·
|
<a href="#quick-start">त्वरित शुरुआत</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">विशेषताएँ</a> ·
|
<a href="#features">विशेषताएँ</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">योगदान</a>
|
<a href="#contributing">योगदान</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil को AI के इर्द-गिर्द शुरू से ब
|
||||||
**कैनवास और ड्रॉइंग**
|
**कैनवास और ड्रॉइंग**
|
||||||
- पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास
|
- पैन, ज़ूम, स्मार्ट अलाइनमेंट गाइड और स्नैपिंग के साथ अनंत कैनवास
|
||||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
||||||
|
- बूलियन ऑपरेशन — संयोजन, घटाना, प्रतिच्छेदन संदर्भ टूलबार के साथ
|
||||||
- आइकन पिकर (Iconify) और इमेज इम्पोर्ट (PNG/JPEG/SVG/WebP/GIF)
|
- आइकन पिकर (Iconify) और इमेज इम्पोर्ट (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- ऑटो-लेआउट — gap, padding, justify, align के साथ वर्टिकल/हॉरिज़ॉन्टल
|
- ऑटो-लेआउट — gap, padding, justify, align के साथ वर्टिकल/हॉरिज़ॉन्टल
|
||||||
- टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़
|
- टैब नेवीगेशन के साथ मल्टी-पेज दस्तावेज़
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल |
|
| `Del` | हटाएँ | | `Cmd+Shift+V` | वेरिएबल पैनल |
|
||||||
| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट |
|
| `[ / ]` | क्रम बदलें | | `Cmd+J` | AI चैट |
|
||||||
| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स |
|
| Arrows | 1px नज | | `Cmd+,` | एजेंट सेटिंग्स |
|
||||||
|
| `Cmd+Alt+U` | बूलियन संयोजन | | `Cmd+Alt+S` | बूलियन घटाना |
|
||||||
|
| `Cmd+Alt+I` | बूलियन प्रतिच्छेदन | | | |
|
||||||
|
|
||||||
## स्क्रिप्ट
|
## स्क्रिप्ट
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Electron पैकेज
|
||||||
- [x] MCP सर्वर इंटीग्रेशन
|
- [x] MCP सर्वर इंटीग्रेशन
|
||||||
- [x] मल्टी-पेज सपोर्ट
|
- [x] मल्टी-पेज सपोर्ट
|
||||||
- [x] Figma `.fig` इम्पोर्ट
|
- [x] Figma `.fig` इम्पोर्ट
|
||||||
- [ ] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट)
|
- [x] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट)
|
||||||
- [ ] सहयोगी संपादन
|
- [ ] सहयोगी संपादन
|
||||||
- [ ] प्लगइन सिस्टम
|
- [ ] प्लगइन सिस्टम
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Electron पैकेज
|
||||||
|
|
||||||
## समुदाय
|
## समुदाय
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> हमारे Discord में शामिल हों</strong>
|
<strong> हमारे Discord में शामिल हों</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.id.md
11
README.id.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#mulai-cepat">Mulai Cepat</a> ·
|
<a href="#mulai-cepat">Mulai Cepat</a> ·
|
||||||
<a href="#desain-berbasis-ai">AI</a> ·
|
<a href="#desain-berbasis-ai">AI</a> ·
|
||||||
<a href="#fitur">Fitur</a> ·
|
<a href="#fitur">Fitur</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#berkontribusi">Berkontribusi</a>
|
<a href="#berkontribusi">Berkontribusi</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil dibangun dengan AI sebagai inti — bukan sebagai plugin, melainkan s
|
||||||
**Kanvas & Menggambar**
|
**Kanvas & Menggambar**
|
||||||
- Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping
|
- Kanvas tak terbatas dengan pan, zoom, panduan perataan cerdas, dan snapping
|
||||||
- Persegi panjang, Elips, Garis, Poligon, Pen (Bezier), Frame, Teks
|
- 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)
|
- Pemilih ikon (Iconify) dan impor gambar (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-layout — vertikal/horizontal dengan gap, padding, justify, align
|
- Auto-layout — vertikal/horizontal dengan gap, padding, justify, align
|
||||||
- Dokumen multi-halaman dengan navigasi tab
|
- Dokumen multi-halaman dengan navigasi tab
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel |
|
| `Del` | Hapus | | `Cmd+Shift+V` | Panel variabel |
|
||||||
| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI |
|
| `[ / ]` | Ubah urutan | | `Cmd+J` | Chat AI |
|
||||||
| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen |
|
| Panah | Geser 1px | | `Cmd+,` | Pengaturan agen |
|
||||||
|
| `Cmd+Alt+U` | Union Boolean | | `Cmd+Alt+S` | Subtract Boolean |
|
||||||
|
| `Cmd+Alt+I` | Intersect Boolean | | | |
|
||||||
|
|
||||||
## Skrip
|
## Skrip
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
|
||||||
- [x] Integrasi server MCP
|
- [x] Integrasi server MCP
|
||||||
- [x] Dukungan multi-halaman
|
- [x] Dukungan multi-halaman
|
||||||
- [x] Impor Figma `.fig`
|
- [x] Impor Figma `.fig`
|
||||||
- [ ] Operasi boolean (gabung, kurangi, potong)
|
- [x] Operasi boolean (gabung, kurangi, potong)
|
||||||
- [ ] Pengeditan kolaboratif
|
- [ ] Pengeditan kolaboratif
|
||||||
- [ ] Sistem plugin
|
- [ ] Sistem plugin
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt
|
||||||
|
|
||||||
## Komunitas
|
## Komunitas
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Bergabung dengan Discord kami</strong>
|
<strong> Bergabung dengan Discord kami</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.ja.md
11
README.ja.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">クイックスタート</a> ·
|
<a href="#quick-start">クイックスタート</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">機能</a> ·
|
<a href="#features">機能</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">コントリビュート</a>
|
<a href="#contributing">コントリビュート</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil はプラグインとしてではなく、コアワークフローと
|
||||||
**キャンバスと描画**
|
**キャンバスと描画**
|
||||||
- パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス
|
- パン、ズーム、スマートアライメントガイド、スナッピング対応の無限キャンバス
|
||||||
- 矩形、楕円、直線、多角形、ペン(ベジェ)、Frame、テキスト
|
- 矩形、楕円、直線、多角形、ペン(ベジェ)、Frame、テキスト
|
||||||
|
- ブーリアン演算 — 合体、型抜き、交差(コンテキストツールバー付き)
|
||||||
- アイコンピッカー(Iconify)と画像インポート(PNG/JPEG/SVG/WebP/GIF)
|
- アイコンピッカー(Iconify)と画像インポート(PNG/JPEG/SVG/WebP/GIF)
|
||||||
- オートレイアウト — 垂直/水平方向、ギャップ・パディング・justify・align 対応
|
- オートレイアウト — 垂直/水平方向、ギャップ・パディング・justify・align 対応
|
||||||
- タブナビゲーション付きマルチページドキュメント
|
- タブナビゲーション付きマルチページドキュメント
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル |
|
| `Del` | 削除 | | `Cmd+Shift+V` | 変数パネル |
|
||||||
| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット |
|
| `[ / ]` | 重ね順の変更 | | `Cmd+J` | AI チャット |
|
||||||
| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 |
|
| 矢印キー | 1px 微調整 | | `Cmd+,` | エージェント設定 |
|
||||||
|
| `Cmd+Alt+U` | ブーリアン合体 | | `Cmd+Alt+S` | ブーリアン型抜き |
|
||||||
|
| `Cmd+Alt+I` | ブーリアン交差 | | | |
|
||||||
|
|
||||||
## スクリプト
|
## スクリプト
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Electron パッケージング
|
||||||
- [x] MCP サーバー統合
|
- [x] MCP サーバー統合
|
||||||
- [x] マルチページサポート
|
- [x] マルチページサポート
|
||||||
- [x] Figma `.fig` インポート
|
- [x] Figma `.fig` インポート
|
||||||
- [ ] ブール演算(結合、減算、交差)
|
- [x] ブール演算(結合、減算、交差)
|
||||||
- [ ] 共同編集
|
- [ ] 共同編集
|
||||||
- [ ] プラグインシステム
|
- [ ] プラグインシステム
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Electron パッケージング
|
||||||
|
|
||||||
## コミュニティ
|
## コミュニティ
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Discord に参加する</strong>
|
<strong> Discord に参加する</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.ko.md
11
README.ko.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">빠른 시작</a> ·
|
<a href="#quick-start">빠른 시작</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">기능</a> ·
|
<a href="#features">기능</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">기여하기</a>
|
<a href="#contributing">기여하기</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil은 AI를 플러그인이 아닌 핵심 워크플로로서 처음부
|
||||||
**캔버스 & 드로잉**
|
**캔버스 & 드로잉**
|
||||||
- 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스
|
- 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스
|
||||||
- 사각형, 타원, 직선, 다각형, 펜(베지어), Frame, 텍스트
|
- 사각형, 타원, 직선, 다각형, 펜(베지어), Frame, 텍스트
|
||||||
|
- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 도구 모음)
|
||||||
- 아이콘 피커(Iconify)와 이미지 가져오기(PNG/JPEG/SVG/WebP/GIF)
|
- 아이콘 피커(Iconify)와 이미지 가져오기(PNG/JPEG/SVG/WebP/GIF)
|
||||||
- 오토 레이아웃 — 수직/수평 방향, 갭·패딩·justify·align 지원
|
- 오토 레이아웃 — 수직/수평 방향, 갭·패딩·justify·align 지원
|
||||||
- 탭 내비게이션이 있는 멀티 페이지 문서
|
- 탭 내비게이션이 있는 멀티 페이지 문서
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 |
|
| `Del` | 삭제 | | `Cmd+Shift+V` | 변수 패널 |
|
||||||
| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 |
|
| `[ / ]` | 순서 변경 | | `Cmd+J` | AI 채팅 |
|
||||||
| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 |
|
| 화살표 키 | 1px 이동 | | `Cmd+,` | 에이전트 설정 |
|
||||||
|
| `Cmd+Alt+U` | 불리언 합치기 | | `Cmd+Alt+S` | 불리언 빼기 |
|
||||||
|
| `Cmd+Alt+I` | 불리언 교차 | | | |
|
||||||
|
|
||||||
## 스크립트
|
## 스크립트
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Electron 패키징
|
||||||
- [x] MCP 서버 통합
|
- [x] MCP 서버 통합
|
||||||
- [x] 멀티 페이지 지원
|
- [x] 멀티 페이지 지원
|
||||||
- [x] Figma `.fig` 가져오기
|
- [x] Figma `.fig` 가져오기
|
||||||
- [ ] 불리언 연산(결합, 빼기, 교차)
|
- [x] 불리언 연산(결합, 빼기, 교차)
|
||||||
- [ ] 공동 편집
|
- [ ] 공동 편집
|
||||||
- [ ] 플러그인 시스템
|
- [ ] 플러그인 시스템
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Electron 패키징
|
||||||
|
|
||||||
## 커뮤니티
|
## 커뮤니티
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Discord에 참여하기</strong>
|
<strong> Discord에 참여하기</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Quick Start</a> ·
|
<a href="#quick-start">Quick Start</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">Features</a> ·
|
<a href="#features">Features</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">Contributing</a>
|
<a href="#contributing">Contributing</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -91,6 +91,7 @@ OpenPencil is built around AI from the ground up — not as a plugin, but as a c
|
||||||
**Canvas & Drawing**
|
**Canvas & Drawing**
|
||||||
- Infinite canvas with pan, zoom, smart alignment guides, and snapping
|
- Infinite canvas with pan, zoom, smart alignment guides, and snapping
|
||||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
- 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)
|
- Icon picker (Iconify) and image import (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-layout — vertical/horizontal with gap, padding, justify, align
|
- Auto-layout — vertical/horizontal with gap, padding, justify, align
|
||||||
- Multi-page documents with tab navigation
|
- 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**
|
**Desktop App**
|
||||||
- Native macOS, Windows, and Linux via Electron
|
- Native macOS, Windows, and Linux via Electron
|
||||||
|
- `.op` file association — double-click to open, single-instance lock
|
||||||
- Auto-update from GitHub Releases
|
- Auto-update from GitHub Releases
|
||||||
- Native application menu and file dialogs
|
- Native application menu and file dialogs
|
||||||
|
|
||||||
|
|
@ -158,6 +160,8 @@ electron/
|
||||||
| `Del` | Delete | | `Cmd+Shift+V` | Variables panel |
|
| `Del` | Delete | | `Cmd+Shift+V` | Variables panel |
|
||||||
| `[ / ]` | Reorder | | `Cmd+J` | AI chat |
|
| `[ / ]` | Reorder | | `Cmd+J` | AI chat |
|
||||||
| Arrows | Nudge 1px | | `Cmd+,` | Agent settings |
|
| Arrows | Nudge 1px | | `Cmd+,` | Agent settings |
|
||||||
|
| `Cmd+Alt+U` | Boolean union | | `Cmd+Alt+S` | Boolean subtract |
|
||||||
|
| `Cmd+Alt+I` | Boolean intersect | | | |
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|
@ -188,7 +192,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
|
||||||
- [x] MCP server integration
|
- [x] MCP server integration
|
||||||
- [x] Multi-page support
|
- [x] Multi-page support
|
||||||
- [x] Figma `.fig` import
|
- [x] Figma `.fig` import
|
||||||
- [ ] Boolean operations (union, subtract, intersect)
|
- [x] Boolean operations (union, subtract, intersect)
|
||||||
- [ ] Collaborative editing
|
- [ ] Collaborative editing
|
||||||
- [ ] Plugin system
|
- [ ] Plugin system
|
||||||
|
|
||||||
|
|
@ -200,7 +204,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Join our Discord</strong>
|
<strong> Join our Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.pt.md
11
README.pt.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#início-rápido">Início Rápido</a> ·
|
<a href="#início-rápido">Início Rápido</a> ·
|
||||||
<a href="#design-nativo-com-ia">IA</a> ·
|
<a href="#design-nativo-com-ia">IA</a> ·
|
||||||
<a href="#funcionalidades">Funcionalidades</a> ·
|
<a href="#funcionalidades">Funcionalidades</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contribuindo">Contribuindo</a>
|
<a href="#contribuindo">Contribuindo</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ O OpenPencil é construído com IA desde o início — não como um plugin, mas
|
||||||
**Canvas e Desenho**
|
**Canvas e Desenho**
|
||||||
- Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping
|
- Canvas infinito com pan, zoom, guias de alinhamento inteligentes e snapping
|
||||||
- Retângulo, Elipse, Linha, Polígono, Caneta (Bezier), Frame, Texto
|
- 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)
|
- Seletor de ícones (Iconify) e importação de imagens (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-layout — vertical/horizontal com gap, padding, justify, align
|
- Auto-layout — vertical/horizontal com gap, padding, justify, align
|
||||||
- Documentos com múltiplas páginas e navegação por abas
|
- 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 |
|
| `Del` | Excluir | | `Cmd+Shift+V` | Painel de variáveis |
|
||||||
| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA |
|
| `[ / ]` | Reordenar | | `Cmd+J` | Chat IA |
|
||||||
| Setas | Mover 1px | | `Cmd+,` | Configurações do agente |
|
| 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
|
## 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] Integração com servidor MCP
|
||||||
- [x] Suporte a múltiplas páginas
|
- [x] Suporte a múltiplas páginas
|
||||||
- [x] Importação do Figma `.fig`
|
- [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
|
- [ ] Edição colaborativa
|
||||||
- [ ] Sistema de plugins
|
- [ ] Sistema de plugins
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh
|
||||||
|
|
||||||
## Comunidade
|
## Comunidade
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Entre no nosso Discord</strong>
|
<strong> Entre no nosso Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.ru.md
11
README.ru.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Быстрый старт</a> ·
|
<a href="#quick-start">Быстрый старт</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">Возможности</a> ·
|
<a href="#features">Возможности</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">Участие в разработке</a>
|
<a href="#contributing">Участие в разработке</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil построен вокруг AI с самого начала — н
|
||||||
**Холст и рисование**
|
**Холст и рисование**
|
||||||
- Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой
|
- Бесконечный холст с панорамированием, масштабированием, умными направляющими и привязкой
|
||||||
- Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст
|
- Прямоугольник, Эллипс, Линия, Многоугольник, Перо (Безье), Frame, Текст
|
||||||
|
- Булевы операции — объединение, вычитание, пересечение с контекстной панелью инструментов
|
||||||
- Выбор иконок (Iconify) и импорт изображений (PNG/JPEG/SVG/WebP/GIF)
|
- Выбор иконок (Iconify) и импорт изображений (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Авто-раскладка — вертикальная/горизонтальная с gap, padding, justify, align
|
- Авто-раскладка — вертикальная/горизонтальная с gap, padding, justify, align
|
||||||
- Многостраничные документы с навигацией по вкладкам
|
- Многостраничные документы с навигацией по вкладкам
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных |
|
| `Del` | Удалить | | `Cmd+Shift+V` | Панель переменных |
|
||||||
| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат |
|
| `[ / ]` | Изменить порядок | | `Cmd+J` | AI-чат |
|
||||||
| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента |
|
| Стрелки | Сдвиг на 1px | | `Cmd+,` | Настройки агента |
|
||||||
|
| `Cmd+Alt+U` | Булево объединение | | `Cmd+Alt+S` | Булево вычитание |
|
||||||
|
| `Cmd+Alt+I` | Булево пересечение | | | |
|
||||||
|
|
||||||
## Скрипты
|
## Скрипты
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Упаковка Electron
|
||||||
- [x] Интеграция с MCP-сервером
|
- [x] Интеграция с MCP-сервером
|
||||||
- [x] Поддержка нескольких страниц
|
- [x] Поддержка нескольких страниц
|
||||||
- [x] Импорт Figma `.fig`
|
- [x] Импорт Figma `.fig`
|
||||||
- [ ] Булевы операции (объединение, вычитание, пересечение)
|
- [x] Булевы операции (объединение, вычитание, пересечение)
|
||||||
- [ ] Совместное редактирование
|
- [ ] Совместное редактирование
|
||||||
- [ ] Система плагинов
|
- [ ] Система плагинов
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Упаковка Electron
|
||||||
|
|
||||||
## Сообщество
|
## Сообщество
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Присоединяйтесь к нашему Discord</strong>
|
<strong> Присоединяйтесь к нашему Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.th.md
11
README.th.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">เริ่มต้นอย่างรวดเร็ว</a> ·
|
<a href="#quick-start">เริ่มต้นอย่างรวดเร็ว</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">ฟีเจอร์</a> ·
|
<a href="#features">ฟีเจอร์</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">มีส่วนร่วม</a>
|
<a href="#contributing">มีส่วนร่วม</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil ถูกสร้างขึ้นโดยมี AI เป็น
|
||||||
**Canvas และการวาด**
|
**Canvas และการวาด**
|
||||||
- Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping
|
- Canvas ไม่จำกัดขนาดพร้อม pan, zoom, smart alignment guides และ snapping
|
||||||
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
- Rectangle, Ellipse, Line, Polygon, Pen (Bezier), Frame, Text
|
||||||
|
- การดำเนินการบูลีน — รวม ลบ ตัดกัน พร้อมแถบเครื่องมือตามบริบท
|
||||||
- ตัวเลือก Icon (Iconify) และนำเข้ารูปภาพ (PNG/JPEG/SVG/WebP/GIF)
|
- ตัวเลือก Icon (Iconify) และนำเข้ารูปภาพ (PNG/JPEG/SVG/WebP/GIF)
|
||||||
- Auto-layout — แนวตั้ง/แนวนอนพร้อม gap, padding, justify, align
|
- Auto-layout — แนวตั้ง/แนวนอนพร้อม gap, padding, justify, align
|
||||||
- เอกสารหลายหน้าพร้อมการนำทางด้วย tab
|
- เอกสารหลายหน้าพร้อมการนำทางด้วย tab
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel |
|
| `Del` | ลบ | | `Cmd+Shift+V` | Variables panel |
|
||||||
| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat |
|
| `[ / ]` | เรียงลำดับ | | `Cmd+J` | AI chat |
|
||||||
| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings |
|
| ลูกศร | เลื่อน 1px | | `Cmd+,` | Agent settings |
|
||||||
|
| `Cmd+Alt+U` | รวมบูลีน | | `Cmd+Alt+S` | ลบบูลีน |
|
||||||
|
| `Cmd+Alt+I` | ตัดกันบูลีน | | | |
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
|
|
@ -186,7 +189,7 @@ bun run electron:build # Electron package
|
||||||
- [x] การเชื่อมต่อ MCP server
|
- [x] การเชื่อมต่อ MCP server
|
||||||
- [x] รองรับหลายหน้า
|
- [x] รองรับหลายหน้า
|
||||||
- [x] นำเข้า Figma `.fig`
|
- [x] นำเข้า Figma `.fig`
|
||||||
- [ ] Boolean operations (union, subtract, intersect)
|
- [x] Boolean operations (union, subtract, intersect)
|
||||||
- [ ] การแก้ไขร่วมกัน
|
- [ ] การแก้ไขร่วมกัน
|
||||||
- [ ] ระบบปลั๊กอิน
|
- [ ] ระบบปลั๊กอิน
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ bun run electron:build # Electron package
|
||||||
|
|
||||||
## ชุมชน
|
## ชุมชน
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> เข้าร่วม Discord ของเรา</strong>
|
<strong> เข้าร่วม Discord ของเรา</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.tr.md
11
README.tr.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#hızlı-başlangıç">Hızlı Başlangıç</a> ·
|
<a href="#hızlı-başlangıç">Hızlı Başlangıç</a> ·
|
||||||
<a href="#ai-destekli-tasarım">AI</a> ·
|
<a href="#ai-destekli-tasarım">AI</a> ·
|
||||||
<a href="#özellikler">Özellikler</a> ·
|
<a href="#özellikler">Özellikler</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#katkıda-bulunma">Katkıda Bulunma</a>
|
<a href="#katkıda-bulunma">Katkıda Bulunma</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -89,6 +89,7 @@ OpenPencil, AI'yi bir eklenti olarak değil, temel iş akışı olarak sıfırda
|
||||||
**Kanvas ve Çizim**
|
**Kanvas ve Çizim**
|
||||||
- Kaydırma, yakınlaştırma, akıllı hizalama kılavuzları ve yakalamayı destekleyen sonsuz kanvas
|
- 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
|
- 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)
|
- 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
|
- Otomatik düzen — boşluk, dolgu, justify, align ile dikey/yatay
|
||||||
- Sekme navigasyonlu çok sayfalı belgeler
|
- Sekme navigasyonlu çok sayfalı belgeler
|
||||||
|
|
@ -156,6 +157,8 @@ electron/
|
||||||
| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli |
|
| `Del` | Sil | | `Cmd+Shift+V` | Değişkenler paneli |
|
||||||
| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet |
|
| `[ / ]` | Yeniden sırala | | `Cmd+J` | AI sohbet |
|
||||||
| Oklar | 1px kaydır | | `Cmd+,` | Ajan ayarları |
|
| 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
|
## 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] MCP sunucu entegrasyonu
|
||||||
- [x] Çok sayfa desteği
|
- [x] Çok sayfa desteği
|
||||||
- [x] Figma `.fig` içe aktarma
|
- [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
|
- [ ] Ortak düzenleme
|
||||||
- [ ] Eklenti sistemi
|
- [ ] Eklenti sistemi
|
||||||
|
|
||||||
|
|
@ -198,7 +201,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md]
|
||||||
|
|
||||||
## Topluluk
|
## Topluluk
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Discord'umuza katılın</strong>
|
<strong> Discord'umuza katılın</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
11
README.vi.md
11
README.vi.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">Bắt đầu nhanh</a> ·
|
<a href="#quick-start">Bắt đầu nhanh</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">Tính năng</a> ·
|
<a href="#features">Tính năng</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">Đóng góp</a>
|
<a href="#contributing">Đóng góp</a>
|
||||||
</p>
|
</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à Vẽ**
|
||||||
- Canvas vô hạn với pan, zoom, hướng dẫn căn chỉnh thông minh và snapping
|
- 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
|
- 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)
|
- 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
|
- 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
|
- 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 |
|
| `Del` | Xóa | | `Cmd+Shift+V` | Bảng biến |
|
||||||
| `[ / ]` | Sắp xếp lại | | `Cmd+J` | AI chat |
|
| `[ / ]` | 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 |
|
| 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
|
## 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] Tích hợp máy chủ MCP
|
||||||
- [x] Hỗ trợ nhiều trang
|
- [x] Hỗ trợ nhiều trang
|
||||||
- [x] Nhập Figma `.fig`
|
- [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
|
- [ ] Chỉnh sửa cộng tác
|
||||||
- [ ] Hệ thống plugin
|
- [ ] 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
|
## Cộng đồng
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> Tham gia Discord của chúng tôi</strong>
|
<strong> Tham gia Discord của chúng tôi</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">快速開始</a> ·
|
<a href="#quick-start">快速開始</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">功能特色</a> ·
|
<a href="#features">功能特色</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">參與貢獻</a>
|
<a href="#contributing">參與貢獻</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -78,6 +78,7 @@ OpenPencil 從底層就圍繞 AI 構建——不是作為外掛程式,而是
|
||||||
**畫布與繪圖**
|
**畫布與繪圖**
|
||||||
- 無限畫布,支援平移、縮放、智慧對齊參考線和吸附
|
- 無限畫布,支援平移、縮放、智慧對齊參考線和吸附
|
||||||
- 矩形、橢圓、直線、多邊形、鋼筆(貝茲曲線)、Frame、文字
|
- 矩形、橢圓、直線、多邊形、鋼筆(貝茲曲線)、Frame、文字
|
||||||
|
- 布林運算 — 聯合、減去、交集,搭配上下文工具列
|
||||||
- 圖示選擇器(Iconify)和圖片匯入(PNG/JPEG/SVG/WebP/GIF)
|
- 圖示選擇器(Iconify)和圖片匯入(PNG/JPEG/SVG/WebP/GIF)
|
||||||
- 自動版面配置 — 垂直/水平方向,支援間距、內邊距、主軸對齊、交叉軸對齊
|
- 自動版面配置 — 垂直/水平方向,支援間距、內邊距、主軸對齊、交叉軸對齊
|
||||||
- 多頁面文件,支援分頁導覽
|
- 多頁面文件,支援分頁導覽
|
||||||
|
|
@ -145,6 +146,8 @@ electron/
|
||||||
| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 |
|
| `Del` | 刪除 | | `Cmd+Shift+V` | 變數面板 |
|
||||||
| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 |
|
| `[ / ]` | 調整圖層順序 | | `Cmd+J` | AI 聊天 |
|
||||||
| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 |
|
| 方向鍵 | 微移 1px | | `Cmd+,` | 智能體設定 |
|
||||||
|
| `Cmd+Alt+U` | 布林聯合 | | `Cmd+Alt+S` | 布林減去 |
|
||||||
|
| `Cmd+Alt+I` | 布林交集 | | | |
|
||||||
|
|
||||||
## 指令碼命令
|
## 指令碼命令
|
||||||
|
|
||||||
|
|
@ -175,7 +178,7 @@ bun run electron:build # Electron 封裝
|
||||||
- [x] MCP 伺服器整合
|
- [x] MCP 伺服器整合
|
||||||
- [x] 多頁面支援
|
- [x] 多頁面支援
|
||||||
- [x] Figma `.fig` 匯入
|
- [x] Figma `.fig` 匯入
|
||||||
- [ ] 布林運算(聯集、減去、交集)
|
- [x] 布林運算(聯集、減去、交集)
|
||||||
- [ ] 協同編輯
|
- [ ] 協同編輯
|
||||||
- [ ] 外掛程式系統
|
- [ ] 外掛程式系統
|
||||||
|
|
||||||
|
|
@ -187,7 +190,7 @@ bun run electron:build # Electron 封裝
|
||||||
|
|
||||||
## 社群
|
## 社群
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> 加入我們的 Discord</strong>
|
<strong> 加入我們的 Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
15
README.zh.md
15
README.zh.md
|
|
@ -17,14 +17,14 @@
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/stargazers"><img src="https://img.shields.io/github/stars/ZSeven-W/openpencil?style=flat" alt="Stars" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/blob/main/LICENSE"><img src="https://img.shields.io/github/license/ZSeven-W/openpencil" alt="License" /></a>
|
||||||
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
<a href="https://github.com/ZSeven-W/openpencil/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/ZSeven-W/openpencil/ci.yml?branch=main&label=CI" alt="CI" /></a>
|
||||||
<a href="https://discord.gg/fE9STbMG"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
<a href="https://discord.gg/KwXp6BJD"><img src="https://img.shields.io/discord/1476517942949580952?label=Discord&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#quick-start">快速开始</a> ·
|
<a href="#quick-start">快速开始</a> ·
|
||||||
<a href="#ai-native-design">AI</a> ·
|
<a href="#ai-native-design">AI</a> ·
|
||||||
<a href="#features">功能特性</a> ·
|
<a href="#features">功能特性</a> ·
|
||||||
<a href="https://discord.gg/fE9STbMG">Discord</a> ·
|
<a href="https://discord.gg/KwXp6BJD">Discord</a> ·
|
||||||
<a href="#contributing">参与贡献</a>
|
<a href="#contributing">参与贡献</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -78,6 +78,7 @@ OpenPencil 从底层就围绕 AI 构建——不是作为插件,而是作为
|
||||||
**画布与绘图**
|
**画布与绘图**
|
||||||
- 无限画布,支持平移、缩放、智能对齐参考线和吸附
|
- 无限画布,支持平移、缩放、智能对齐参考线和吸附
|
||||||
- 矩形、椭圆、直线、多边形、钢笔(贝塞尔)、Frame、文本
|
- 矩形、椭圆、直线、多边形、钢笔(贝塞尔)、Frame、文本
|
||||||
|
- 布尔运算 — 联合、减去、交集,配合上下文工具栏
|
||||||
- 图标选择器(Iconify)和图片导入(PNG/JPEG/SVG/WebP/GIF)
|
- 图标选择器(Iconify)和图片导入(PNG/JPEG/SVG/WebP/GIF)
|
||||||
- 自动布局 — 垂直/水平方向,支持间距、内边距、主轴对齐、交叉轴对齐
|
- 自动布局 — 垂直/水平方向,支持间距、内边距、主轴对齐、交叉轴对齐
|
||||||
- 多页面文档,支持标签页导航
|
- 多页面文档,支持标签页导航
|
||||||
|
|
@ -145,6 +146,8 @@ electron/
|
||||||
| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 |
|
| `Del` | 删除 | | `Cmd+Shift+V` | 变量面板 |
|
||||||
| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 |
|
| `[ / ]` | 调整层级顺序 | | `Cmd+J` | AI 聊天 |
|
||||||
| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 |
|
| 方向键 | 微移 1px | | `Cmd+,` | 智能体设置 |
|
||||||
|
| `Cmd+Alt+U` | 布尔联合 | | `Cmd+Alt+S` | 布尔减去 |
|
||||||
|
| `Cmd+Alt+I` | 布尔交集 | | | |
|
||||||
|
|
||||||
## 脚本命令
|
## 脚本命令
|
||||||
|
|
||||||
|
|
@ -175,7 +178,7 @@ bun run electron:build # Electron 打包
|
||||||
- [x] MCP 服务器集成
|
- [x] MCP 服务器集成
|
||||||
- [x] 多页面支持
|
- [x] 多页面支持
|
||||||
- [x] Figma `.fig` 导入
|
- [x] Figma `.fig` 导入
|
||||||
- [ ] 布尔运算(合并、减去、相交)
|
- [x] 布尔运算(合并、减去、相交)
|
||||||
- [ ] 协同编辑
|
- [ ] 协同编辑
|
||||||
- [ ] 插件系统
|
- [ ] 插件系统
|
||||||
|
|
||||||
|
|
@ -187,12 +190,16 @@ bun run electron:build # Electron 打包
|
||||||
|
|
||||||
## 社区
|
## 社区
|
||||||
|
|
||||||
<a href="https://discord.gg/fE9STbMG">
|
<a href="https://discord.gg/KwXp6BJD">
|
||||||
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
<img src="./public/logo-discord.svg" alt="Discord" width="16" />
|
||||||
<strong> 加入我们的 Discord</strong>
|
<strong> 加入我们的 Discord</strong>
|
||||||
</a>
|
</a>
|
||||||
— 提问、分享设计、提出功能建议。
|
— 提问、分享设计、提出功能建议。
|
||||||
|
|
||||||
|
**飞书交流群**
|
||||||
|
|
||||||
|
<img src="./screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg" alt="飞书交流群" width="240" />
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
[MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W
|
[MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W
|
||||||
|
|
|
||||||
14
bun.lock
14
bun.lock
|
|
@ -31,12 +31,14 @@
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fabric": "^7.1.0",
|
"fabric": "^7.1.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.8.14",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"kiwi-schema": "^0.5.0",
|
"kiwi-schema": "^0.5.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"paper": "^0.12.18",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^16.5.4",
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,14 @@ linux:
|
||||||
- AppImage
|
- AppImage
|
||||||
- deb
|
- deb
|
||||||
|
|
||||||
|
fileAssociations:
|
||||||
|
- ext: op
|
||||||
|
name: OpenPencil Document
|
||||||
|
description: OpenPencil Design File
|
||||||
|
mimeType: application/x-openpencil
|
||||||
|
role: Editor
|
||||||
|
icon: build/icon
|
||||||
|
|
||||||
asar: true
|
asar: true
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
|
|
|
||||||
126
electron/main.ts
126
electron/main.ts
|
|
@ -20,9 +20,14 @@ let nitroProcess: ChildProcess | null = null
|
||||||
let serverPort = 0
|
let serverPort = 0
|
||||||
let autoUpdateEnabled = true
|
let autoUpdateEnabled = true
|
||||||
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
|
let updateCheckTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let pendingFilePath: string | null = null
|
||||||
|
|
||||||
const isDev = !app.isPackaged
|
const isDev = !app.isPackaged
|
||||||
const SETTINGS_PATH = join(homedir(), '.openpencil', 'settings.json')
|
// Settings stored in platform-standard app data dir (Electron-managed):
|
||||||
|
// macOS: ~/Library/Application Support/OpenPencil/
|
||||||
|
// Windows: %APPDATA%\OpenPencil\
|
||||||
|
// Linux: ~/.config/OpenPencil/
|
||||||
|
const SETTINGS_PATH = join(app.getPath('userData'), 'settings.json')
|
||||||
|
|
||||||
type UpdaterStatus =
|
type UpdaterStatus =
|
||||||
| 'disabled'
|
| 'disabled'
|
||||||
|
|
@ -218,7 +223,7 @@ async function readAppSettings(): Promise<AppSettings> {
|
||||||
async function writeAppSettings(patch: Partial<AppSettings>): Promise<void> {
|
async function writeAppSettings(patch: Partial<AppSettings>): Promise<void> {
|
||||||
const current = await readAppSettings()
|
const current = await readAppSettings()
|
||||||
const merged = { ...current, ...patch }
|
const merged = { ...current, ...patch }
|
||||||
await mkdir(PORT_FILE_DIR, { recursive: true })
|
await mkdir(app.getPath('userData'), { recursive: true })
|
||||||
await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8')
|
await writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,8 +242,8 @@ async function writePortFile(port: number): Promise<void> {
|
||||||
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
|
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
|
||||||
'utf-8',
|
'utf-8',
|
||||||
)
|
)
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Non-critical — MCP sync will fall back to file I/O
|
console.error('[port-file] Failed to write port file:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -379,8 +384,9 @@ function createWindow(): void {
|
||||||
...(isWinOrLinux
|
...(isWinOrLinux
|
||||||
? {
|
? {
|
||||||
titleBarOverlay: {
|
titleBarOverlay: {
|
||||||
color: 'rgba(0,0,0,0)',
|
// Windows supports transparent overlay; Linux needs solid color
|
||||||
symbolColor: '#a1a1aa',
|
color: process.platform === 'win32' ? 'rgba(0,0,0,0)' : '#111',
|
||||||
|
symbolColor: '#d4d4d8',
|
||||||
height: 36,
|
height: 36,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -389,6 +395,9 @@ function createWindow(): void {
|
||||||
preload: join(__dirname, 'preload.cjs'),
|
preload: join(__dirname, 'preload.cjs'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
// Persist localStorage/cookies in a fixed partition so data survives
|
||||||
|
// across random Nitro server port changes (origin-independent storage).
|
||||||
|
partition: 'persist:openpencil',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -523,17 +532,45 @@ function setupIPC(): void {
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Theme sync for Windows/Linux title bar overlay
|
ipcMain.handle('file:getPending', () => {
|
||||||
ipcMain.handle('theme:set', (_event, theme: 'dark' | 'light') => {
|
if (pendingFilePath) {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
const filePath = pendingFilePath
|
||||||
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
|
pendingFilePath = null
|
||||||
if (!isWinOrLinux) return
|
return filePath
|
||||||
mainWindow.setTitleBarOverlay({
|
}
|
||||||
color: 'rgba(0,0,0,0)',
|
return null
|
||||||
symbolColor: theme === 'dark' ? '#a1a1aa' : '#71717a',
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:read', async (_event, filePath: string) => {
|
||||||
|
const resolved = resolve(filePath)
|
||||||
|
const ext = extname(resolved).toLowerCase()
|
||||||
|
if (ext !== '.op' && ext !== '.pen') return null
|
||||||
|
try {
|
||||||
|
const content = await readFile(resolved, 'utf-8')
|
||||||
|
return { filePath: resolved, content }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Theme sync for Windows/Linux title bar overlay
|
||||||
|
ipcMain.handle(
|
||||||
|
'theme:set',
|
||||||
|
(_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
|
const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux'
|
||||||
|
if (!isWinOrLinux) return
|
||||||
|
const isLinux = process.platform === 'linux'
|
||||||
|
const fallbackBg = theme === 'dark' ? '#111' : '#fff'
|
||||||
|
const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46'
|
||||||
|
mainWindow.setTitleBarOverlay({
|
||||||
|
// Windows supports transparent overlay; Linux uses actual CSS card color
|
||||||
|
color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)',
|
||||||
|
symbolColor: colors?.fg || fallbackFg,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle('updater:getState', () => updaterState)
|
ipcMain.handle('updater:getState', () => updaterState)
|
||||||
|
|
||||||
ipcMain.handle('updater:checkForUpdates', async () => {
|
ipcMain.handle('updater:checkForUpdates', async () => {
|
||||||
|
|
@ -699,6 +736,58 @@ function buildAppMenu(): void {
|
||||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File association: open .op files
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Extract .op file path from command-line arguments. */
|
||||||
|
function getFilePathFromArgs(args: string[]): string | null {
|
||||||
|
for (const arg of args) {
|
||||||
|
if (arg.endsWith('.op') || arg.endsWith('.pen')) {
|
||||||
|
return arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a file path to the renderer for loading. */
|
||||||
|
function sendOpenFile(filePath: string): void {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('file:open', filePath)
|
||||||
|
} else {
|
||||||
|
pendingFilePath = filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS: open-file fires when user double-clicks a .op file
|
||||||
|
app.on('open-file', (event, filePath) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (app.isReady()) {
|
||||||
|
sendOpenFile(filePath)
|
||||||
|
} else {
|
||||||
|
pendingFilePath = filePath
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Single instance lock (Windows/Linux: second instance passes file path as arg)
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', (_event, argv) => {
|
||||||
|
const filePath = getFilePathFromArgs(argv)
|
||||||
|
if (filePath) {
|
||||||
|
sendOpenFile(filePath)
|
||||||
|
}
|
||||||
|
// Focus existing window
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// App lifecycle
|
// App lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -725,6 +814,13 @@ app.on('ready', async () => {
|
||||||
|
|
||||||
createWindow()
|
createWindow()
|
||||||
|
|
||||||
|
// Check for file to open: pending open-file event or CLI args (Windows/Linux).
|
||||||
|
// The file path is stored in pendingFilePath and pulled by the renderer
|
||||||
|
// via file:getPending IPC when the React app mounts (useElectronMenu hook).
|
||||||
|
if (!pendingFilePath) {
|
||||||
|
pendingFilePath = getFilePathFromArgs(process.argv)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isDev) {
|
if (!isDev) {
|
||||||
const settings = await readAppSettings()
|
const settings = await readAppSettings()
|
||||||
autoUpdateEnabled = settings.autoUpdate !== false
|
autoUpdateEnabled = settings.autoUpdate !== false
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,10 @@ export interface ElectronAPI {
|
||||||
) => Promise<string | null>
|
) => Promise<string | null>
|
||||||
saveToPath: (filePath: string, content: string) => Promise<string>
|
saveToPath: (filePath: string, content: string) => Promise<string>
|
||||||
onMenuAction: (callback: (action: string) => void) => () => void
|
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<string | null>
|
||||||
|
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => void
|
||||||
updater: {
|
updater: {
|
||||||
getState: () => Promise<UpdaterState>
|
getState: () => Promise<UpdaterState>
|
||||||
checkForUpdates: () => Promise<UpdaterState>
|
checkForUpdates: () => Promise<UpdaterState>
|
||||||
|
|
@ -50,7 +53,8 @@ const api: ElectronAPI = {
|
||||||
saveToPath: (filePath: string, content: string) =>
|
saveToPath: (filePath: string, content: string) =>
|
||||||
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
|
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
|
||||||
|
|
||||||
setTheme: (theme: 'dark' | 'light') => ipcRenderer.invoke('theme:set', theme),
|
setTheme: (theme: 'dark' | 'light', colors?: { bg: string; fg: string }) =>
|
||||||
|
ipcRenderer.invoke('theme:set', theme, colors),
|
||||||
|
|
||||||
onMenuAction: (callback: (action: string) => void) => {
|
onMenuAction: (callback: (action: string) => void) => {
|
||||||
const listener = (_event: IpcRendererEvent, action: string) => {
|
const listener = (_event: IpcRendererEvent, action: string) => {
|
||||||
|
|
@ -62,6 +66,20 @@ const api: ElectronAPI = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onOpenFile: (callback: (filePath: string) => void) => {
|
||||||
|
const listener = (_event: IpcRendererEvent, filePath: string) => {
|
||||||
|
callback(filePath)
|
||||||
|
}
|
||||||
|
ipcRenderer.on('file:open', listener)
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('file:open', listener)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
|
||||||
|
|
||||||
|
getPendingFile: () => ipcRenderer.invoke('file:getPending'),
|
||||||
|
|
||||||
updater: {
|
updater: {
|
||||||
getState: () => ipcRenderer.invoke('updater:getState'),
|
getState: () => ipcRenderer.invoke('updater:getState'),
|
||||||
checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'),
|
checkForUpdates: () => ipcRenderer.invoke('updater:checkForUpdates'),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "openpencil",
|
"name": "openpencil",
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"description": "Open-source vector design tool with Design-as-Code philosophy",
|
"description": "Open-source vector design tool with Design-as-Code philosophy",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ZSeven-W",
|
"name": "ZSeven-W",
|
||||||
|
|
@ -54,12 +54,14 @@
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fabric": "^7.1.0",
|
"fabric": "^7.1.0",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.8.14",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"kiwi-schema": "^0.5.0",
|
"kiwi-schema": "^0.5.0",
|
||||||
"lucide-react": "^0.545.0",
|
"lucide-react": "^0.545.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nitro": "npm:nitro-nightly@latest",
|
"nitro": "npm:nitro-nightly@latest",
|
||||||
|
"paper": "^0.12.18",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
|
|
|
||||||
BIN
screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg
Normal file
BIN
screenshot/557517811-62010928-d91a-4223-bc10-9ee7a4fbf043.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
|
|
@ -117,10 +117,11 @@ async function connectCodexCli(): Promise<ConnectResult> {
|
||||||
const { join } = await import('node:path')
|
const { join } = await import('node:path')
|
||||||
|
|
||||||
// Check if codex binary exists
|
// Check if codex binary exists
|
||||||
const which = execSync('which codex 2>/dev/null || echo ""', {
|
const whichCmd = process.platform === 'win32' ? 'where codex 2>nul' : 'which codex 2>/dev/null || echo ""'
|
||||||
|
const which = execSync(whichCmd, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
}).trim()
|
}).trim().split(/\r?\n/)[0]?.trim() ?? ''
|
||||||
|
|
||||||
if (!which) {
|
if (!which) {
|
||||||
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
|
return { connected: false, models: [], notInstalled: true, error: 'Codex CLI not found' }
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,20 @@ function toImageBase64(data: string): string {
|
||||||
async function withTempImageFile<T>(
|
async function withTempImageFile<T>(
|
||||||
imageBase64: string,
|
imageBase64: string,
|
||||||
run: (tempPath: string) => Promise<T>,
|
run: (tempPath: string) => Promise<T>,
|
||||||
|
insideProject = false,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'))
|
let tempDir: string
|
||||||
|
if (insideProject) {
|
||||||
|
// Save inside the project directory so Claude Code Agent SDK (plan mode)
|
||||||
|
// can read the file — it restricts reads to the project directory.
|
||||||
|
const { mkdirSync, chmodSync } = await import('node:fs')
|
||||||
|
const baseDir = join(process.cwd(), '.openpencil-tmp')
|
||||||
|
mkdirSync(baseDir, { recursive: true })
|
||||||
|
chmodSync(baseDir, 0o700)
|
||||||
|
tempDir = await mkdtemp(join(baseDir, 'validate-'))
|
||||||
|
} else {
|
||||||
|
tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-'))
|
||||||
|
}
|
||||||
const tempPath = join(tempDir, 'screenshot.png')
|
const tempPath = join(tempDir, 'screenshot.png')
|
||||||
try {
|
try {
|
||||||
await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64'))
|
await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64'))
|
||||||
|
|
@ -75,8 +87,10 @@ async function withTempImageFile<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent SDK fallback: save screenshot to a temp PNG file, then ask Claude
|
* Agent SDK: save screenshot to a temp PNG file inside the project directory,
|
||||||
* Code to read it (Claude Code's Read tool supports images natively).
|
* then ask Claude Code to read it (Claude Code's Read tool supports images
|
||||||
|
* natively). Must use insideProject=true because plan mode restricts reads
|
||||||
|
* to the project directory.
|
||||||
*/
|
*/
|
||||||
async function validateViaAgentSDK(
|
async function validateViaAgentSDK(
|
||||||
body: ValidateBody,
|
body: ValidateBody,
|
||||||
|
|
@ -87,23 +101,23 @@ async function validateViaAgentSDK(
|
||||||
|
|
||||||
const env = buildClaudeAgentEnv()
|
const env = buildClaudeAgentEnv()
|
||||||
const debugFile = getClaudeAgentDebugFilePath()
|
const debugFile = getClaudeAgentDebugFilePath()
|
||||||
|
|
||||||
const claudePath = resolveClaudeCli()
|
const claudePath = resolveClaudeCli()
|
||||||
|
|
||||||
// Prompt Claude Code to read the temp image and analyze it
|
const prompt = `IMPORTANT: First, use the Read tool to read the image file at "${tempPath}". This is a PNG screenshot of a UI design.
|
||||||
const prompt = `Read the image file at "${tempPath}" and analyze it as a UI design screenshot.
|
|
||||||
|
|
||||||
${body.message}
|
After viewing the image, analyze it according to these instructions:
|
||||||
|
|
||||||
${body.system}
|
${body.system}
|
||||||
|
|
||||||
Output ONLY the JSON object, no markdown fences, no explanation.`
|
${body.message}
|
||||||
|
|
||||||
|
CRITICAL: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no tool calls after reading the image. Just the JSON.`
|
||||||
|
|
||||||
const q = query({
|
const q = query({
|
||||||
prompt,
|
prompt,
|
||||||
options: {
|
options: {
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
maxTurns: 2,
|
maxTurns: 3,
|
||||||
tools: [],
|
tools: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
permissionMode: 'plan',
|
permissionMode: 'plan',
|
||||||
|
|
@ -131,7 +145,7 @@ Output ONLY the JSON object, no markdown fences, no explanation.`
|
||||||
}
|
}
|
||||||
|
|
||||||
return { text: '', skipped: true }
|
return { text: '', skipped: true }
|
||||||
})
|
}, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateViaCodex(
|
async function validateViaCodex(
|
||||||
|
|
|
||||||
48
server/plugins/port-file.ts
Normal file
48
server/plugins/port-file.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* Nitro plugin — writes ~/.openpencil/.port on server startup so the MCP
|
||||||
|
* server can discover the running instance (dev server or Electron).
|
||||||
|
*
|
||||||
|
* In Electron production mode the main process also writes this file,
|
||||||
|
* but this plugin ensures the dev server (`bun --bun run dev`) is
|
||||||
|
* discoverable too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFile, mkdir, unlink } from 'node:fs/promises'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { homedir } from 'node:os'
|
||||||
|
|
||||||
|
const PORT_FILE_DIR = join(homedir(), '.openpencil')
|
||||||
|
const PORT_FILE_PATH = join(PORT_FILE_DIR, '.port')
|
||||||
|
|
||||||
|
async function writePortFile(port: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await mkdir(PORT_FILE_DIR, { recursive: true })
|
||||||
|
await writeFile(
|
||||||
|
PORT_FILE_PATH,
|
||||||
|
JSON.stringify({ port, pid: process.pid, timestamp: Date.now() }),
|
||||||
|
'utf-8',
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Non-critical — MCP sync will fall back to file I/O
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupPortFile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unlink(PORT_FILE_PATH)
|
||||||
|
} catch {
|
||||||
|
// Ignore if already removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const port = parseInt(process.env.PORT || '3000', 10)
|
||||||
|
writePortFile(port)
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
cleanupPortFile()
|
||||||
|
}
|
||||||
|
process.on('beforeExit', cleanup)
|
||||||
|
process.on('SIGINT', cleanup)
|
||||||
|
process.on('SIGTERM', cleanup)
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,12 @@ const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
|
||||||
* Only passes through safe system vars and provider-specific prefixes.
|
* Only passes through safe system vars and provider-specific prefixes.
|
||||||
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
|
* Prevents leaking secrets like ANTHROPIC_API_KEY, AWS_SECRET_KEY, GITHUB_TOKEN, etc.
|
||||||
*/
|
*/
|
||||||
const CODEX_ENV_ALLOWLIST = new Set(['PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR'])
|
const CODEX_ENV_ALLOWLIST = new Set([
|
||||||
|
'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR',
|
||||||
|
// Windows-essential vars
|
||||||
|
'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
||||||
|
'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH',
|
||||||
|
])
|
||||||
|
|
||||||
export function filterCodexEnv(
|
export function filterCodexEnv(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
|
|
@ -146,6 +151,8 @@ async function executeCodexCommand(
|
||||||
const child = spawn('codex', args, {
|
const child = spawn('codex', args, {
|
||||||
env: filterCodexEnv(process.env as Record<string, string | undefined>),
|
env: filterCodexEnv(process.env as Record<string, string | undefined>),
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
// On Windows, npm-installed CLIs are .cmd scripts — need shell to resolve them
|
||||||
|
...(process.platform === 'win32' && { shell: true }),
|
||||||
})
|
})
|
||||||
|
|
||||||
let stdoutBuffer = ''
|
let stdoutBuffer = ''
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const isWindows = process.platform === 'win32'
|
||||||
|
|
||||||
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
|
/** Resolve the standalone copilot CLI binary path to avoid Bun's node:sqlite issue */
|
||||||
export function resolveCopilotCli(): string | undefined {
|
export function resolveCopilotCli(): string | undefined {
|
||||||
try {
|
try {
|
||||||
const path = execSync('which copilot 2>/dev/null', { encoding: 'utf-8', timeout: 5000 }).trim()
|
const cmd = isWindows ? 'where copilot 2>nul' : 'which copilot 2>/dev/null'
|
||||||
|
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim()
|
||||||
|
// `where` on Windows may return multiple lines
|
||||||
|
const path = result.split(/\r?\n/)[0]?.trim()
|
||||||
return path || undefined
|
return path || undefined
|
||||||
} catch {
|
} catch {
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,28 @@
|
||||||
import { fork, type ChildProcess } from 'node:child_process'
|
import { fork, execSync, type ChildProcess } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
import { networkInterfaces } from 'node:os'
|
import { networkInterfaces } from 'node:os'
|
||||||
import { resolve } from 'node:path'
|
import { join, resolve } from 'node:path'
|
||||||
|
|
||||||
let mcpProcess: ChildProcess | null = null
|
let mcpProcess: ChildProcess | null = null
|
||||||
let mcpPort: number | null = null
|
let mcpPort: number | null = null
|
||||||
|
|
||||||
|
/** Resolve the MCP server script path across dev, web build, and Electron production. */
|
||||||
|
function resolveMcpServerScript(): string {
|
||||||
|
// Electron production: extraResources
|
||||||
|
const electronResources = process.env.ELECTRON_RESOURCES_PATH
|
||||||
|
if (electronResources) {
|
||||||
|
const p = join(electronResources, 'mcp-server.cjs')
|
||||||
|
if (existsSync(p)) return p
|
||||||
|
}
|
||||||
|
// dev + web build
|
||||||
|
const fromCwd = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
|
||||||
|
if (existsSync(fromCwd)) return fromCwd
|
||||||
|
// Fallback: relative to this file (Nitro bundled output)
|
||||||
|
const fromFile = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
|
||||||
|
if (existsSync(fromFile)) return fromFile
|
||||||
|
return fromCwd
|
||||||
|
}
|
||||||
|
|
||||||
/** Get the first non-internal IPv4 address (LAN IP). */
|
/** Get the first non-internal IPv4 address (LAN IP). */
|
||||||
export function getLocalIp(): string | null {
|
export function getLocalIp(): string | null {
|
||||||
const nets = networkInterfaces()
|
const nets = networkInterfaces()
|
||||||
|
|
@ -32,7 +50,7 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
|
||||||
return { running: true, port: mcpPort!, localIp: getLocalIp() }
|
return { running: true, port: mcpPort!, localIp: getLocalIp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverScript = resolve(process.cwd(), 'dist/mcp-server.cjs')
|
const serverScript = resolveMcpServerScript()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
mcpProcess = fork(serverScript, ['--http', '--port', String(port)], {
|
mcpProcess = fork(serverScript, ['--http', '--port', String(port)], {
|
||||||
|
|
@ -60,7 +78,17 @@ export function startMcpHttpServer(port: number): { running: boolean; port: numb
|
||||||
|
|
||||||
export function stopMcpHttpServer(): { running: false } {
|
export function stopMcpHttpServer(): { running: false } {
|
||||||
if (mcpProcess) {
|
if (mcpProcess) {
|
||||||
mcpProcess.kill('SIGTERM')
|
if (process.platform === 'win32') {
|
||||||
|
// SIGTERM is unreliable on Windows; use taskkill for proper cleanup
|
||||||
|
try {
|
||||||
|
const pid = mcpProcess.pid
|
||||||
|
if (pid) {
|
||||||
|
execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'ignore' })
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
mcpProcess.kill('SIGTERM')
|
||||||
|
}
|
||||||
mcpProcess = null
|
mcpProcess = null
|
||||||
mcpPort = null
|
mcpPort = null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { homedir } from 'node:os'
|
import { homedir, platform } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
const isWindows = platform() === 'win32'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the absolute path to the standalone `claude` binary.
|
* Resolve the absolute path to the standalone `claude` binary.
|
||||||
*
|
*
|
||||||
|
|
@ -13,23 +15,31 @@ import { join } from 'node:path'
|
||||||
* binaries and spawns them directly (no `node` wrapper needed).
|
* binaries and spawns them directly (no `node` wrapper needed).
|
||||||
*/
|
*/
|
||||||
export function resolveClaudeCli(): string | undefined {
|
export function resolveClaudeCli(): string | undefined {
|
||||||
// 1. Try `which claude` (works when PATH is correctly set)
|
// 1. Try PATH lookup
|
||||||
try {
|
try {
|
||||||
const p = execSync('which claude 2>/dev/null', {
|
const cmd = isWindows ? 'where claude' : 'which claude 2>/dev/null'
|
||||||
|
const p = execSync(cmd, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
}).trim()
|
}).trim().split(/\r?\n/)[0] // `where` on Windows may return multiple lines
|
||||||
if (p && existsSync(p)) return p
|
if (p && existsSync(p)) return p
|
||||||
} catch { /* not in PATH */ }
|
} catch { /* not in PATH */ }
|
||||||
|
|
||||||
// 2. Common install locations
|
// 2. Common install locations
|
||||||
const candidates = [
|
const candidates = isWindows
|
||||||
join(homedir(), '.local', 'bin', 'claude'),
|
? [
|
||||||
'/usr/local/bin/claude',
|
join(process.env.LOCALAPPDATA || '', 'Programs', 'claude-code', 'claude.exe'),
|
||||||
'/opt/homebrew/bin/claude',
|
join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'claude.exe'),
|
||||||
]
|
join(homedir(), '.claude', 'local', 'claude.exe'),
|
||||||
|
join(homedir(), 'AppData', 'Local', 'Programs', 'claude-code', 'claude.exe'),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
join(homedir(), '.local', 'bin', 'claude'),
|
||||||
|
'/usr/local/bin/claude',
|
||||||
|
'/opt/homebrew/bin/claude',
|
||||||
|
]
|
||||||
for (const c of candidates) {
|
for (const c of candidates) {
|
||||||
if (existsSync(c)) return c
|
if (c && existsSync(c)) return c
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
|
||||||
import {
|
import {
|
||||||
parseSizing,
|
parseSizing,
|
||||||
estimateTextWidth,
|
estimateTextWidth,
|
||||||
|
estimateTextWidthPrecise,
|
||||||
estimateTextHeight,
|
estimateTextHeight,
|
||||||
estimateLineWidth,
|
estimateLineWidth,
|
||||||
getTextOpticalCenterYOffset,
|
getTextOpticalCenterYOffset,
|
||||||
|
defaultLineHeight,
|
||||||
} from './canvas-text-measure'
|
} from './canvas-text-measure'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -175,7 +177,11 @@ export function getNodeWidth(node: PenNode, parentAvail?: number): number {
|
||||||
typeof node.content === 'string'
|
typeof node.content === 'string'
|
||||||
? node.content
|
? node.content
|
||||||
: node.content.map((s) => s.text).join('')
|
: node.content.map((s) => s.text).join('')
|
||||||
return Math.max(Math.ceil(estimateTextWidth(content, fontSize, letterSpacing)), 20)
|
// Use precise estimation (no safety factor) for fit-content / natural-width
|
||||||
|
// text. IText auto-computes its own width and ignores ours, so the safety
|
||||||
|
// margin only inflates the layout allocation, making the text appear
|
||||||
|
// left-shifted within its overwide box.
|
||||||
|
return Math.max(Math.ceil(estimateTextWidthPrecise(content, fontSize, letterSpacing)), 20)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
@ -299,19 +305,16 @@ export function computeLayoutPositions(
|
||||||
const childCross = isVertical ? size.w : size.h
|
const childCross = isVertical ? size.w : size.h
|
||||||
let crossPos = 0
|
let crossPos = 0
|
||||||
|
|
||||||
// For text nodes, use the actual Fabric-rendered height for cross-axis
|
// For text nodes in horizontal layout with center alignment, use the actual
|
||||||
// centering instead of the declared height. Fabric.js text height =
|
// Fabric-rendered height (fontSize * lineHeight) instead of the declared
|
||||||
// fontSize * lineHeight, which is typically smaller than the AI-declared
|
// height, since Fabric text is shorter than AI-declared height.
|
||||||
// height, causing text to appear shifted upward when centered.
|
|
||||||
let effectiveChildCross = childCross
|
let effectiveChildCross = childCross
|
||||||
if (align === 'center' && child.type === 'text') {
|
if (align === 'center' && child.type === 'text') {
|
||||||
const fontSize = child.fontSize ?? 16
|
const fontSize = child.fontSize ?? 16
|
||||||
const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? 1.2
|
const lineHeight = ('lineHeight' in child ? child.lineHeight : undefined) ?? defaultLineHeight(fontSize)
|
||||||
const visualH = fontSize * lineHeight
|
const visualH = fontSize * lineHeight
|
||||||
if (!isVertical && visualH < childCross) {
|
if (!isVertical && visualH < childCross) {
|
||||||
effectiveChildCross = visualH
|
effectiveChildCross = visualH
|
||||||
} else if (isVertical && visualH < childCross) {
|
|
||||||
// vertical layout: cross axis is width, not applicable
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,8 +343,8 @@ export function computeLayoutPositions(
|
||||||
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
|
crossPos = Math.max(0, Math.min(crossPos, crossAvail - clampCrossSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
const computedX = isVertical ? pad.left + crossPos : pad.left + mainPos
|
const computedX = Math.round(isVertical ? pad.left + crossPos : pad.left + mainPos)
|
||||||
const computedY = isVertical ? pad.top + mainPos : pad.top + crossPos
|
const computedY = Math.round(isVertical ? pad.top + mainPos : pad.top + crossPos)
|
||||||
|
|
||||||
mainPos += (isVertical ? size.h : size.w) + effectiveGap
|
mainPos += (isVertical ? size.h : size.w) + effectiveGap
|
||||||
|
|
||||||
|
|
@ -355,6 +358,35 @@ export function computeLayoutPositions(
|
||||||
width: size.w,
|
width: size.w,
|
||||||
height: size.h,
|
height: size.h,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For text nodes centered in a vertical layout, expand to full available
|
||||||
|
// width and set textAlign:'center'. This avoids width estimation inaccuracy:
|
||||||
|
// IText ignores our width and computes its own, so textAlign has no effect.
|
||||||
|
// By using full width (which triggers Textbox in the factory) + center align,
|
||||||
|
// the text is precisely centered regardless of glyph estimation error.
|
||||||
|
if (isVertical && align === 'center' && child.type === 'text') {
|
||||||
|
const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
|
||||||
|
if (!hasExplicitAlign) {
|
||||||
|
out.width = availW
|
||||||
|
out.x = Math.round(pad.left)
|
||||||
|
out.textAlign = 'center'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For fit-content text nodes in horizontal layouts, set textAlign:'center'
|
||||||
|
// to compensate for width estimation inaccuracy. The estimated box is
|
||||||
|
// typically slightly wider than the actual rendered text, so left-aligned
|
||||||
|
// text appears visually shifted left. Centering the text within its box
|
||||||
|
// distributes the error evenly on both sides.
|
||||||
|
if (!isVertical && child.type === 'text') {
|
||||||
|
const hasExplicitAlign = 'textAlign' in child && child.textAlign && child.textAlign !== 'left'
|
||||||
|
const widthMode = 'width' in child ? parseSizing(child.width) : 0
|
||||||
|
const isFitContent = widthMode === 'fit' || widthMode === 0
|
||||||
|
if (!hasExplicitAlign && isFitContent) {
|
||||||
|
out.textAlign = 'center'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out as unknown as PenNode
|
return out as unknown as PenNode
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DEFAULT_STROKE_WIDTH,
|
DEFAULT_STROKE_WIDTH,
|
||||||
SELECTION_BLUE,
|
SELECTION_BLUE,
|
||||||
} from './canvas-constants'
|
} from './canvas-constants'
|
||||||
|
import { defaultLineHeight } from './canvas-text-measure'
|
||||||
import { applyRotationControls } from './canvas-controls'
|
import { applyRotationControls } from './canvas-controls'
|
||||||
|
|
||||||
function angleToCoords(
|
function angleToCoords(
|
||||||
|
|
@ -150,7 +151,13 @@ function shouldSplitByGrapheme(text: string): boolean {
|
||||||
|
|
||||||
function isFixedWidthText(node: PenNode): boolean {
|
function isFixedWidthText(node: PenNode): boolean {
|
||||||
if (node.type !== 'text') return false
|
if (node.type !== 'text') return false
|
||||||
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
|
if (node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height') return true
|
||||||
|
// When textAlign is not 'left', the layout engine injected centering.
|
||||||
|
// IText ignores width and computes its own, making textAlign ineffective
|
||||||
|
// for single-line text. Use Textbox so the width is respected and
|
||||||
|
// textAlign:'center' actually centers the text within the box.
|
||||||
|
if (node.textAlign && node.textAlign !== 'left') return true
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function sizeToNumber(
|
function sizeToNumber(
|
||||||
|
|
@ -425,7 +432,7 @@ export function createFabricObject(
|
||||||
textAlign: node.textAlign ?? 'left',
|
textAlign: node.textAlign ?? 'left',
|
||||||
underline: node.underline ?? false,
|
underline: node.underline ?? false,
|
||||||
linethrough: node.strikethrough ?? false,
|
linethrough: node.strikethrough ?? false,
|
||||||
lineHeight: node.lineHeight ?? 1.2,
|
lineHeight: node.lineHeight ?? defaultLineHeight(node.fontSize ?? 16),
|
||||||
charSpacing: node.letterSpacing
|
charSpacing: node.letterSpacing
|
||||||
? (node.letterSpacing / (node.fontSize || 16)) * 1000
|
? (node.letterSpacing / (node.fontSize || 16)) * 1000
|
||||||
: 0,
|
: 0,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,20 @@ export function parseSizing(value: unknown): number | 'fit' | 'fill' {
|
||||||
return isNaN(n) ? 0 : n
|
return isNaN(n) ? 0 : n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default line height — single source of truth for all modules
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical default lineHeight when a text node has no explicit value.
|
||||||
|
* Display/heading text (>=28px) gets tighter spacing; body text gets looser.
|
||||||
|
* All modules (factory, layout engine, text estimation, AI generation)
|
||||||
|
* MUST use this function instead of hardcoded fallbacks.
|
||||||
|
*/
|
||||||
|
export function defaultLineHeight(fontSize: number): number {
|
||||||
|
return fontSize >= 28 ? 1.2 : 1.5
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CJK detection
|
// CJK detection
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -85,6 +99,19 @@ export function estimateTextWidth(text: string, fontSize: number, letterSpacing
|
||||||
return maxLine
|
return maxLine
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Estimate text width WITHOUT safety factor.
|
||||||
|
* Used for layout centering where the safety margin causes text to appear
|
||||||
|
* off-center (the overestimated width shifts the text box left when centered).
|
||||||
|
* For wrapping/sizing decisions, use estimateTextWidth() which includes the safety factor.
|
||||||
|
*/
|
||||||
|
export function estimateTextWidthPrecise(text: string, fontSize: number, letterSpacing = 0): number {
|
||||||
|
const lines = text.split(/\r?\n/)
|
||||||
|
return lines.reduce((max, line) => {
|
||||||
|
return Math.max(max, estimateLineWidth(line, fontSize, letterSpacing))
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Text content helpers
|
// Text content helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -108,7 +135,8 @@ export function countExplicitTextLines(text: string): number {
|
||||||
/**
|
/**
|
||||||
* Optical vertical correction for centered single-line text.
|
* Optical vertical correction for centered single-line text.
|
||||||
* Font line boxes are mathematically centered but glyph ink tends to look
|
* Font line boxes are mathematically centered but glyph ink tends to look
|
||||||
* slightly top-heavy, especially for CJK, so we nudge down by 1-3px.
|
* slightly top-heavy, especially for CJK, so we nudge down proportionally.
|
||||||
|
* The offset scales with fontSize (no fixed cap) so large text stays centered.
|
||||||
*/
|
*/
|
||||||
export function getTextOpticalCenterYOffset(node: PenNode): number {
|
export function getTextOpticalCenterYOffset(node: PenNode): number {
|
||||||
if (node.type !== 'text') return 0
|
if (node.type !== 'text') return 0
|
||||||
|
|
@ -117,13 +145,16 @@ export function getTextOpticalCenterYOffset(node: PenNode): number {
|
||||||
if (countExplicitTextLines(text) > 1) return 0
|
if (countExplicitTextLines(text) > 1) return 0
|
||||||
|
|
||||||
const fontSize = node.fontSize ?? 16
|
const fontSize = node.fontSize ?? 16
|
||||||
const lineHeight = node.lineHeight ?? 1.2
|
const lineHeight = node.lineHeight ?? defaultLineHeight(fontSize)
|
||||||
const hasCjk = hasCjkText(text)
|
const hasCjk = hasCjkText(text)
|
||||||
|
|
||||||
const ratio = hasCjk ? 0.16 : 0.1
|
// Base ratio: CJK glyphs sit higher in the em box than Latin
|
||||||
const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.7
|
const ratio = hasCjk ? 0.12 : 0.07
|
||||||
|
// When lineHeight is compact (≤1.35), the visual offset is more pronounced
|
||||||
|
const compactLineBoost = lineHeight <= 1.35 ? 1 : 0.65
|
||||||
const offset = fontSize * ratio * compactLineBoost
|
const offset = fontSize * ratio * compactLineBoost
|
||||||
return Math.max(1, Math.min(5, Math.round(offset)))
|
// Proportional cap: never exceed 8% of fontSize, minimum 1px
|
||||||
|
return Math.max(1, Math.min(Math.round(fontSize * 0.08), Math.round(offset)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -135,7 +166,7 @@ export function estimateTextHeight(node: PenNode, availableWidth?: number): numb
|
||||||
// Access text-specific properties via Record to avoid union type issues
|
// Access text-specific properties via Record to avoid union type issues
|
||||||
const n = node as unknown as Record<string, unknown>
|
const n = node as unknown as Record<string, unknown>
|
||||||
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
|
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
|
||||||
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : 1.2)
|
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : defaultLineHeight(fontSize))
|
||||||
const singleLineH = fontSize * lineHeight
|
const singleLineH = fontSize * lineHeight
|
||||||
|
|
||||||
// Get text content
|
// Get text content
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@
|
||||||
*
|
*
|
||||||
* Visual effects:
|
* Visual effects:
|
||||||
* 1. Soft colored border on nodes being generated (breathing opacity)
|
* 1. Soft colored border on nodes being generated (breathing opacity)
|
||||||
* 2. Agent badge next to the frame name with a spinning dot animation
|
* 2. Breathing glow border around agent-owned frames (same color as badge)
|
||||||
* 3. Preview fill tint on nodes that haven't materialized yet
|
* 3. Agent badge right-aligned above the frame with a spinning dot animation
|
||||||
|
* 4. Preview fill tint on nodes that haven't materialized yet
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useCanvasStore } from '@/stores/canvas-store'
|
import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
import { useDocumentStore } from '@/stores/document-store'
|
|
||||||
import {
|
import {
|
||||||
getActiveAgentIndicators,
|
getActiveAgentIndicators,
|
||||||
getActiveAgentFrames,
|
getActiveAgentFrames,
|
||||||
|
|
@ -23,7 +23,6 @@ const BADGE_FONT_SIZE = 11
|
||||||
const BADGE_PAD_X = 6
|
const BADGE_PAD_X = 6
|
||||||
const BADGE_PAD_Y = 3
|
const BADGE_PAD_Y = 3
|
||||||
const BADGE_RADIUS = 4
|
const BADGE_RADIUS = 4
|
||||||
const BADGE_GAP = 6
|
|
||||||
const DOT_RADIUS = 3
|
const DOT_RADIUS = 3
|
||||||
|
|
||||||
export function useAgentIndicators() {
|
export function useAgentIndicators() {
|
||||||
|
|
@ -106,25 +105,39 @@ export function useAgentIndicators() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Draw agent badges next to frame names ----
|
// ---- Draw agent frame glow borders + badges ----
|
||||||
for (const frame of agentFrames.values()) {
|
for (const frame of agentFrames.values()) {
|
||||||
const obj = objMap.get(frame.frameId)
|
const obj = objMap.get(frame.frameId)
|
||||||
if (!obj) continue
|
if (!obj) continue
|
||||||
|
|
||||||
const corners = obj.getCoords()
|
const corners = obj.getCoords()
|
||||||
const x = Math.min(...corners.map((p) => p.x))
|
const xs = corners.map((p) => p.x)
|
||||||
const y = Math.min(...corners.map((p) => p.y))
|
const ys = corners.map((p) => p.y)
|
||||||
|
const fx = Math.min(...xs)
|
||||||
|
const fy = Math.min(...ys)
|
||||||
|
const fw = Math.max(...xs) - fx
|
||||||
|
const fh = Math.max(...ys) - fy
|
||||||
|
if (fw < 1 || fh < 1) continue
|
||||||
|
|
||||||
// Measure the existing frame label width to position badge after it
|
// --- Breathing glow border around the frame ---
|
||||||
|
const glowWidth = 1.5 / zoom
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = frame.color
|
||||||
|
ctx.lineWidth = glowWidth
|
||||||
|
ctx.globalAlpha = breath * 0.8
|
||||||
|
// Outer glow (wider, more transparent)
|
||||||
|
ctx.shadowColor = frame.color
|
||||||
|
ctx.shadowBlur = 8 / zoom
|
||||||
|
ctx.strokeRect(fx, fy, fw, fh)
|
||||||
|
// Inner crisp border (no shadow)
|
||||||
|
ctx.shadowColor = 'transparent'
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
ctx.globalAlpha = breath
|
||||||
|
ctx.strokeRect(fx, fy, fw, fh)
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// --- Badge: right-aligned above the frame ---
|
||||||
const fontSize = BADGE_FONT_SIZE / zoom
|
const fontSize = BADGE_FONT_SIZE / zoom
|
||||||
const labelFontSize = 12 / zoom
|
|
||||||
ctx.font = `500 ${labelFontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
|
||||||
const docStore = useDocumentStore.getState()
|
|
||||||
const frameNode = docStore.getNodeById(frame.frameId)
|
|
||||||
const frameName = frameNode?.name ?? frameNode?.type ?? ''
|
|
||||||
const labelWidth = frameName ? ctx.measureText(frameName).width + BADGE_GAP / zoom : 0
|
|
||||||
|
|
||||||
// Badge position: right of frame label, above frame
|
|
||||||
const padX = BADGE_PAD_X / zoom
|
const padX = BADGE_PAD_X / zoom
|
||||||
const padY = BADGE_PAD_Y / zoom
|
const padY = BADGE_PAD_Y / zoom
|
||||||
const radius = BADGE_RADIUS / zoom
|
const radius = BADGE_RADIUS / zoom
|
||||||
|
|
@ -133,12 +146,12 @@ export function useAgentIndicators() {
|
||||||
|
|
||||||
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
||||||
const textWidth = ctx.measureText(frame.name).width
|
const textWidth = ctx.measureText(frame.name).width
|
||||||
// Space for spinning dot + text
|
|
||||||
const dotSpace = dotR * 2 + 4 / zoom
|
const dotSpace = dotR * 2 + 4 / zoom
|
||||||
const badgeW = dotSpace + textWidth + padX * 2
|
const badgeW = dotSpace + textWidth + padX * 2
|
||||||
const badgeH = fontSize + padY * 2
|
const badgeH = fontSize + padY * 2
|
||||||
const badgeX = x + labelWidth
|
// Right-align badge to frame's right edge
|
||||||
const badgeY = y - labelOffsetY - badgeH
|
const badgeX = fx + fw - badgeW
|
||||||
|
const badgeY = fy - labelOffsetY - badgeH
|
||||||
|
|
||||||
// Badge background (pill shape)
|
// Badge background (pill shape)
|
||||||
ctx.globalAlpha = 0.9
|
ctx.globalAlpha = 0.9
|
||||||
|
|
|
||||||
|
|
@ -467,11 +467,13 @@ export function useCanvasSync() {
|
||||||
// class/shape data changes (e.g. IText↔Textbox).
|
// class/shape data changes (e.g. IText↔Textbox).
|
||||||
// Path `d` changes are handled in-place by syncFabricObject.
|
// Path `d` changes are handled in-place by syncFabricObject.
|
||||||
let objectRecreated = false
|
let objectRecreated = false
|
||||||
// Text nodes may need recreation when textGrowth mode changes
|
// Text nodes may need recreation when textGrowth mode or textAlign changes
|
||||||
// (IText ↔ Textbox are different Fabric classes).
|
// (IText ↔ Textbox are different Fabric classes).
|
||||||
|
// Must match isFixedWidthText() in canvas-object-factory.ts.
|
||||||
if (existingObj && node.type === 'text') {
|
if (existingObj && node.type === 'text') {
|
||||||
const growth = node.textGrowth
|
const growth = node.textGrowth
|
||||||
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height'
|
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height'
|
||||||
|
|| (node.textAlign != null && node.textAlign !== 'left')
|
||||||
const isTextbox = existingObj instanceof fabric.Textbox
|
const isTextbox = existingObj instanceof fabric.Textbox
|
||||||
if (needsTextbox !== isTextbox) {
|
if (needsTextbox !== isTextbox) {
|
||||||
canvas.remove(existingObj)
|
canvas.remove(existingObj)
|
||||||
|
|
|
||||||
88
src/components/editor/boolean-toolbar.tsx
Normal file
88
src/components/editor/boolean-toolbar.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import {
|
||||||
|
SquaresUnite,
|
||||||
|
SquaresSubtract,
|
||||||
|
SquaresIntersect,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
|
import { useDocumentStore } from '@/stores/document-store'
|
||||||
|
import { useHistoryStore } from '@/stores/history-store'
|
||||||
|
import type { PenNode } from '@/types/pen'
|
||||||
|
import {
|
||||||
|
canBooleanOp,
|
||||||
|
executeBooleanOp,
|
||||||
|
type BooleanOpType,
|
||||||
|
} from '@/utils/boolean-ops'
|
||||||
|
|
||||||
|
const OPS = [
|
||||||
|
{ type: 'union' as BooleanOpType, icon: SquaresUnite, labelKey: 'layerMenu.booleanUnion', shortcut: '\u2318\u2325U' },
|
||||||
|
{ type: 'subtract' as BooleanOpType, icon: SquaresSubtract, labelKey: 'layerMenu.booleanSubtract', shortcut: '\u2318\u2325S' },
|
||||||
|
{ type: 'intersect' as BooleanOpType, icon: SquaresIntersect, labelKey: 'layerMenu.booleanIntersect', shortcut: '\u2318\u2325I' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export default function BooleanToolbar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
|
||||||
|
|
||||||
|
const nodes = selectedIds
|
||||||
|
.map((id) => useDocumentStore.getState().getNodeById(id))
|
||||||
|
.filter((n): n is PenNode => n != null)
|
||||||
|
|
||||||
|
const show = canBooleanOp(nodes)
|
||||||
|
|
||||||
|
const handleOp = useCallback((opType: BooleanOpType) => {
|
||||||
|
const { selectedIds } = useCanvasStore.getState().selection
|
||||||
|
const currentNodes = selectedIds
|
||||||
|
.map((id) => useDocumentStore.getState().getNodeById(id))
|
||||||
|
.filter((n): n is PenNode => n != null)
|
||||||
|
|
||||||
|
if (!canBooleanOp(currentNodes)) return
|
||||||
|
const result = executeBooleanOp(currentNodes, opType)
|
||||||
|
if (!result) return
|
||||||
|
|
||||||
|
useHistoryStore.getState().pushState(useDocumentStore.getState().document)
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
useDocumentStore.getState().removeNode(id)
|
||||||
|
}
|
||||||
|
useDocumentStore.getState().addNode(null, result)
|
||||||
|
useCanvasStore.getState().setSelection([result.id], result.id)
|
||||||
|
const canvas = useCanvasStore.getState().fabricCanvas
|
||||||
|
if (canvas) {
|
||||||
|
canvas.discardActiveObject()
|
||||||
|
canvas.requestRenderAll()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!show) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-2 left-14 z-10 bg-card border border-border rounded-xl flex items-center py-1 px-1 gap-0.5 shadow-lg">
|
||||||
|
{OPS.map((op) => (
|
||||||
|
<Tooltip key={op.type}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOp(op.type)}
|
||||||
|
aria-label={t(op.labelKey)}
|
||||||
|
className="inline-flex items-center justify-center h-7 w-7 rounded-lg transition-colors text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
<op.icon size={16} strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
{t(op.labelKey)}
|
||||||
|
<kbd className="ml-1.5 inline-flex h-4 items-center rounded border border-border/50 bg-muted px-1 font-mono text-[10px] text-muted-foreground">
|
||||||
|
{op.shortcut}
|
||||||
|
</kbd>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,11 @@ import { lazy, Suspense, useState, useCallback, useEffect } from 'react'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
import TopBar from './top-bar'
|
import TopBar from './top-bar'
|
||||||
import Toolbar from './toolbar'
|
import Toolbar from './toolbar'
|
||||||
|
import BooleanToolbar from './boolean-toolbar'
|
||||||
import StatusBar from './status-bar'
|
import StatusBar from './status-bar'
|
||||||
import LayerPanel from '@/components/panels/layer-panel'
|
import LayerPanel from '@/components/panels/layer-panel'
|
||||||
import PropertyPanel from '@/components/panels/property-panel'
|
import RightPanel from '@/components/panels/right-panel'
|
||||||
import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel'
|
import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel'
|
||||||
import CodePanel from '@/components/panels/code-panel'
|
|
||||||
import VariablesPanel from '@/components/panels/variables-panel'
|
import VariablesPanel from '@/components/panels/variables-panel'
|
||||||
import ComponentBrowserPanel from '@/components/panels/component-browser-panel'
|
import ComponentBrowserPanel from '@/components/panels/component-browser-panel'
|
||||||
import ExportDialog from '@/components/shared/export-dialog'
|
import ExportDialog from '@/components/shared/export-dialog'
|
||||||
|
|
@ -36,17 +36,12 @@ export default function EditorLayout() {
|
||||||
useCanvasStore.getState().setFigmaImportDialogOpen(false)
|
useCanvasStore.getState().setFigmaImportDialogOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
const browserOpen = useUIKitStore((s) => s.browserOpen)
|
const browserOpen = useUIKitStore((s) => s.browserOpen)
|
||||||
const codePanelOpen = useCanvasStore((s) => s.codePanelOpen)
|
|
||||||
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
|
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
|
||||||
const closeSaveDialog = useCallback(() => {
|
const closeSaveDialog = useCallback(() => {
|
||||||
useDocumentStore.getState().setSaveDialogOpen(false)
|
useDocumentStore.getState().setSaveDialogOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
|
|
||||||
const toggleCodePanel = useCallback(() => {
|
|
||||||
useCanvasStore.getState().toggleCodePanel()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const closeExport = useCallback(() => {
|
const closeExport = useCallback(() => {
|
||||||
setExportOpen(false)
|
setExportOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -62,10 +57,10 @@ export default function EditorLayout() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cmd+Shift+C: toggle code panel
|
// Cmd+Shift+C: switch right panel to code tab
|
||||||
if (isMod && e.shiftKey && e.key.toLowerCase() === 'c') {
|
if (isMod && e.shiftKey && e.key.toLowerCase() === 'c') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
toggleCodePanel()
|
useCanvasStore.getState().setRightPanelTab('code')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +101,7 @@ export default function EditorLayout() {
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
}, [toggleMinimize, toggleCodePanel])
|
}, [toggleMinimize])
|
||||||
|
|
||||||
// Handle Electron native menu actions
|
// Handle Electron native menu actions
|
||||||
useElectronMenu()
|
useElectronMenu()
|
||||||
|
|
@ -144,6 +139,7 @@ export default function EditorLayout() {
|
||||||
<FabricCanvas />
|
<FabricCanvas />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
<BooleanToolbar />
|
||||||
|
|
||||||
{/* Floating variables panel — anchored to the right of the toolbar */}
|
{/* Floating variables panel — anchored to the right of the toolbar */}
|
||||||
{variablesPanelOpen && <VariablesPanel />}
|
{variablesPanelOpen && <VariablesPanel />}
|
||||||
|
|
@ -164,9 +160,8 @@ export default function EditorLayout() {
|
||||||
{/* Expanded AI panel (floating, draggable) */}
|
{/* Expanded AI panel (floating, draggable) */}
|
||||||
<AIChatPanel />
|
<AIChatPanel />
|
||||||
</div>
|
</div>
|
||||||
{hasSelection && <PropertyPanel />}
|
{hasSelection && <RightPanel />}
|
||||||
</div>
|
</div>
|
||||||
{codePanelOpen && <CodePanel onClose={() => useCanvasStore.getState().setCodePanelOpen(false)} />}
|
|
||||||
</div>
|
</div>
|
||||||
<ExportDialog open={exportOpen} onClose={closeExport} />
|
<ExportDialog open={exportOpen} onClose={closeExport} />
|
||||||
<SaveDialog open={saveDialogOpen} onClose={closeSaveDialog} />
|
<SaveDialog open={saveDialogOpen} onClose={closeSaveDialog} />
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,21 @@ import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
|
||||||
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
|
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
|
||||||
import type { AIProviderType } from '@/types/agent-settings'
|
import type { AIProviderType } from '@/types/agent-settings'
|
||||||
|
|
||||||
|
/** Convert a computed CSS color value (oklch/rgb/etc.) to #rrggbb via an offscreen canvas. */
|
||||||
|
function cssToHex(raw: string): string | null {
|
||||||
|
const v = raw.trim()
|
||||||
|
if (!v) return null
|
||||||
|
try {
|
||||||
|
const ctx = document.createElement('canvas').getContext('2d')
|
||||||
|
if (!ctx) return null
|
||||||
|
ctx.fillStyle = v
|
||||||
|
const hex = ctx.fillStyle // browser normalises to #rrggbb
|
||||||
|
return hex.startsWith('#') ? hex : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDER_ICONS: Record<AIProviderType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
const PROVIDER_ICONS: Record<AIProviderType, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||||
anthropic: ClaudeLogo,
|
anthropic: ClaudeLogo,
|
||||||
openai: OpenAILogo,
|
openai: OpenAILogo,
|
||||||
|
|
@ -123,6 +138,18 @@ export default function TopBar() {
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
|
||||||
|
// Read computed CSS --card and --card-foreground as hex for Electron overlay
|
||||||
|
const syncOverlayColors = useCallback((t: 'dark' | 'light') => {
|
||||||
|
if (!window.electronAPI?.setTheme) return
|
||||||
|
// Allow a frame for CSS to apply after class toggle
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const s = getComputedStyle(document.documentElement)
|
||||||
|
const bg = cssToHex(s.getPropertyValue('--card'))
|
||||||
|
const fg = cssToHex(s.getPropertyValue('--card-foreground'))
|
||||||
|
window.electronAPI!.setTheme(t, bg && fg ? { bg, fg } : undefined)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Restore saved theme after hydration
|
// Restore saved theme after hydration
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -130,14 +157,14 @@ export default function TopBar() {
|
||||||
if (saved === 'light') {
|
if (saved === 'light') {
|
||||||
document.documentElement.classList.add('light')
|
document.documentElement.classList.add('light')
|
||||||
setTheme('light')
|
setTheme('light')
|
||||||
window.electronAPI?.setTheme?.('light')
|
syncOverlayColors('light')
|
||||||
} else {
|
} else {
|
||||||
window.electronAPI?.setTheme?.('dark')
|
syncOverlayColors('dark')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [])
|
}, [syncOverlayColors])
|
||||||
|
|
||||||
// Listen to fullscreen changes
|
// Listen to fullscreen changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -154,13 +181,13 @@ export default function TopBar() {
|
||||||
document.documentElement.classList.remove('light')
|
document.documentElement.classList.remove('light')
|
||||||
}
|
}
|
||||||
setTheme(next)
|
setTheme(next)
|
||||||
window.electronAPI?.setTheme?.(next)
|
syncOverlayColors(next)
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('openpencil-theme', next)
|
localStorage.setItem('openpencil-theme', next)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, [theme])
|
}, [theme, syncOverlayColors])
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
const toggleFullscreen = useCallback(() => {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { Pencil, ChevronDown, Check } from 'lucide-react'
|
import { Pencil, ChevronDown, Check, AlertTriangle } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,6 +8,13 @@ import {
|
||||||
buildPipelineProgress,
|
buildPipelineProgress,
|
||||||
} from './chat-message'
|
} from './chat-message'
|
||||||
|
|
||||||
|
/** Parse [done]/[pending]/[error] prefix from a detail line */
|
||||||
|
function parseDetailStatus(line: string): { status: 'done' | 'pending' | 'error' | null; text: string } {
|
||||||
|
const match = line.match(/^\[(done|pending|error)\]\s*(.*)$/)
|
||||||
|
if (match) return { status: match[1] as 'done' | 'pending' | 'error', text: match[2] }
|
||||||
|
return { status: null, text: line }
|
||||||
|
}
|
||||||
|
|
||||||
/** Fixed collapsible checklist pinned between messages and input */
|
/** Fixed collapsible checklist pinned between messages and input */
|
||||||
export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
|
export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
|
@ -27,7 +34,7 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
|
||||||
const planSteps = steps.filter((s) => s.title !== 'Thinking')
|
const planSteps = steps.filter((s) => s.title !== 'Thinking')
|
||||||
if (planSteps.length === 0) return []
|
if (planSteps.length === 0) return []
|
||||||
const jsonCount = countDesignJsonBlocks(content)
|
const jsonCount = countDesignJsonBlocks(content)
|
||||||
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->')
|
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->') || content.includes('[done] Applied')
|
||||||
const hasError = /\*\*Error:\*\*/i.test(content)
|
const hasError = /\*\*Error:\*\*/i.test(content)
|
||||||
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
|
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
|
||||||
}, [lastAssistant, isStreaming])
|
}, [lastAssistant, isStreaming])
|
||||||
|
|
@ -64,27 +71,54 @@ export function FixedChecklist({ messages, isStreaming }: { messages: ChatMessag
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="px-3 pb-2.5 flex max-h-44 flex-col gap-1 overflow-y-auto">
|
<div className="px-3 pb-2.5 flex max-h-44 flex-col gap-1 overflow-y-auto">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={`${item.label}-${index}`} className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
|
<div key={`${item.label}-${index}`} className="flex flex-col gap-0.5">
|
||||||
<span
|
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
|
||||||
className={cn(
|
<span
|
||||||
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
|
className={cn(
|
||||||
item.done
|
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
|
||||||
? 'border-emerald-500/70 text-emerald-500/80'
|
item.done
|
||||||
: item.active
|
? 'border-emerald-500/70 text-emerald-500/80'
|
||||||
? 'border-primary/70 text-primary'
|
: item.active
|
||||||
: 'border-border/70 text-muted-foreground/50',
|
? 'border-primary/70 text-primary'
|
||||||
)}
|
: 'border-border/70 text-muted-foreground/50',
|
||||||
>
|
)}
|
||||||
{item.done ? (
|
>
|
||||||
<Check size={9} strokeWidth={2.5} />
|
{item.done ? (
|
||||||
) : (
|
<Check size={9} strokeWidth={2.5} />
|
||||||
<span className={cn(
|
) : (
|
||||||
'w-1.5 h-1.5 rounded-full',
|
<span className={cn(
|
||||||
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
|
'w-1.5 h-1.5 rounded-full',
|
||||||
)} />
|
item.active ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60',
|
||||||
)}
|
)} />
|
||||||
</span>
|
)}
|
||||||
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
|
</span>
|
||||||
|
<span className={cn(item.active ? 'text-foreground' : '')}>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
{item.details && item.details.length > 0 && (
|
||||||
|
<div className="ml-[22px] flex flex-col gap-px">
|
||||||
|
{item.details.map((line, di) => {
|
||||||
|
const { status, text } = parseDetailStatus(line)
|
||||||
|
return (
|
||||||
|
<span key={di} className="flex items-center gap-1.5 text-[10px] text-muted-foreground/70">
|
||||||
|
{status === 'done' && (
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full border border-emerald-500/70 text-emerald-500/80 flex items-center justify-center shrink-0">
|
||||||
|
<Check size={7} strokeWidth={2.5} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === 'pending' && (
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full border border-primary/70 flex items-center justify-center shrink-0">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-primary animate-pulse" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<AlertTriangle size={10} className="text-amber-500/80 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{text}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,44 @@ import { trimChatHistory } from '@/services/ai/context-optimizer'
|
||||||
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
||||||
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
|
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
|
||||||
|
|
||||||
/** Detect if a message is a design generation request */
|
/** Intent classification prompt — lightweight LLM call to determine message routing */
|
||||||
export function isDesignRequest(text: string): boolean {
|
const CLASSIFY_PROMPT = `You are a UI design tool assistant. Classify the user's message intent.
|
||||||
const lower = text.toLowerCase()
|
Reply with EXACTLY one of these tags, nothing else:
|
||||||
const designKeywords = [
|
- DESIGN — user wants to create, generate, or modify any UI element, component, screen, or page
|
||||||
'生成', '设计', '创建', '画', '做一个', '来一个', '弄一个',
|
- CHAT — user is asking a question, seeking help, or having a conversation`
|
||||||
'generate', 'create', 'design', 'make', 'build', 'draw',
|
|
||||||
'add a', 'add an', 'place a', 'insert',
|
/** Classify user intent via a lightweight LLM call instead of hardcoded keyword matching */
|
||||||
'界面', '页面', 'screen', 'page', 'layout', 'component',
|
async function classifyIntent(
|
||||||
'按钮', '卡片', '导航', '表单', '输入框', '列表',
|
text: string,
|
||||||
'button', 'card', 'nav', 'form', 'input', 'list',
|
model: string,
|
||||||
'header', 'footer', 'sidebar', 'modal', 'dialog',
|
provider?: string,
|
||||||
'login', 'signup', 'dashboard', 'profile',
|
): Promise<{ isDesign: boolean }> {
|
||||||
]
|
try {
|
||||||
return designKeywords.some((kw) => lower.includes(kw))
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 8_000)
|
||||||
|
|
||||||
|
const response = await fetch('/api/ai/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
system: CLASSIFY_PROMPT,
|
||||||
|
message: text,
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
clearTimeout(timeout)
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('classify failed')
|
||||||
|
const data = await response.json()
|
||||||
|
const upper = (data.text ?? '').trim().toUpperCase()
|
||||||
|
|
||||||
|
return { isDesign: upper.includes('DESIGN') }
|
||||||
|
} catch {
|
||||||
|
// Fallback: in a design tool, default to design mode
|
||||||
|
return { isDesign: true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContextString(): string {
|
export function buildContextString(): string {
|
||||||
|
|
@ -96,8 +120,6 @@ export function useChatHandlers() {
|
||||||
// Determine context and mode
|
// Determine context and mode
|
||||||
const selectedIds = useCanvasStore.getState().selection.selectedIds
|
const selectedIds = useCanvasStore.getState().selection.selectedIds
|
||||||
const hasSelection = selectedIds.length > 0
|
const hasSelection = selectedIds.length > 0
|
||||||
const isDesign = isDesignRequest(messageText)
|
|
||||||
const isModification = isDesign && hasSelection
|
|
||||||
|
|
||||||
const context = buildContextString()
|
const context = buildContextString()
|
||||||
const fullUserMessage = messageText + context
|
const fullUserMessage = messageText + context
|
||||||
|
|
@ -141,11 +163,19 @@ export function useChatHandlers() {
|
||||||
|
|
||||||
let accumulated = ''
|
let accumulated = ''
|
||||||
let appliedCount = 0
|
let appliedCount = 0
|
||||||
|
let isDesign = false
|
||||||
|
|
||||||
const abortController = new AbortController()
|
const abortController = new AbortController()
|
||||||
useAIStore.getState().setAbortController(abortController)
|
useAIStore.getState().setAbortController(abortController)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Classify intent via lightweight LLM call
|
||||||
|
const classified = await classifyIntent(
|
||||||
|
messageText, model, currentProvider,
|
||||||
|
)
|
||||||
|
isDesign = classified.isDesign
|
||||||
|
const isModification = isDesign && hasSelection
|
||||||
|
|
||||||
if (isDesign) {
|
if (isDesign) {
|
||||||
if (isModification) {
|
if (isModification) {
|
||||||
// --- MODIFICATION MODE ---
|
// --- MODIFICATION MODE ---
|
||||||
|
|
@ -177,6 +207,7 @@ export function useChatHandlers() {
|
||||||
model,
|
model,
|
||||||
provider: currentProvider,
|
provider: currentProvider,
|
||||||
concurrency,
|
concurrency,
|
||||||
|
mode: 'visual-ref',
|
||||||
context: {
|
context: {
|
||||||
canvasSize: { width: 1200, height: 800 },
|
canvasSize: { width: 1200, height: 800 },
|
||||||
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
|
documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`,
|
||||||
|
|
|
||||||
|
|
@ -143,16 +143,31 @@ export function countDesignJsonBlocks(text: string): number {
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PipelineItem {
|
||||||
|
label: string
|
||||||
|
done: boolean
|
||||||
|
active: boolean
|
||||||
|
/** Optional detail lines (e.g. validation log) */
|
||||||
|
details?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPipelineProgress(
|
export function buildPipelineProgress(
|
||||||
steps: ParsedStep[],
|
steps: ParsedStep[],
|
||||||
jsonBlockCount: number,
|
jsonBlockCount: number,
|
||||||
isStreaming: boolean,
|
isStreaming: boolean,
|
||||||
isApplied: boolean,
|
isApplied: boolean,
|
||||||
hasError: boolean,
|
hasError: boolean,
|
||||||
): Array<{ label: string; done: boolean; active: boolean }> {
|
): PipelineItem[] {
|
||||||
// No steps = no checklist
|
// No steps = no checklist
|
||||||
if (steps.length === 0) return []
|
if (steps.length === 0) return []
|
||||||
|
|
||||||
|
// Parse detail lines from step content (one line per entry)
|
||||||
|
function extractDetails(content: string): string[] | undefined {
|
||||||
|
if (!content) return undefined
|
||||||
|
const lines = content.split('\n').map((l) => l.trim()).filter(Boolean)
|
||||||
|
return lines.length > 0 ? lines : undefined
|
||||||
|
}
|
||||||
|
|
||||||
// If steps have explicit status (orchestrator mode), use that directly.
|
// If steps have explicit status (orchestrator mode), use that directly.
|
||||||
// Check this BEFORE terminal result logic so that user-stopped generations
|
// Check this BEFORE terminal result logic so that user-stopped generations
|
||||||
// preserve the actual per-step status instead of marking everything done.
|
// preserve the actual per-step status instead of marking everything done.
|
||||||
|
|
@ -162,13 +177,14 @@ export function buildPipelineProgress(
|
||||||
label: s.title,
|
label: s.title,
|
||||||
done: s.status === 'done',
|
done: s.status === 'done',
|
||||||
active: isStreaming && s.status === 'streaming',
|
active: isStreaming && s.status === 'streaming',
|
||||||
|
details: extractDetails(s.content),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// If generation is complete and applied, mark all steps done
|
// If generation is complete and applied, mark all steps done
|
||||||
const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0)
|
const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0)
|
||||||
if (hasTerminalResult) {
|
if (hasTerminalResult) {
|
||||||
return steps.map((s) => ({ label: s.title, done: true, active: false }))
|
return steps.map((s) => ({ label: s.title, done: true, active: false, details: extractDetails(s.content) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Map each step to done/active/pending based on completed JSON blocks.
|
// Fallback: Map each step to done/active/pending based on completed JSON blocks.
|
||||||
|
|
@ -177,7 +193,7 @@ export function buildPipelineProgress(
|
||||||
return steps.map((s, index) => {
|
return steps.map((s, index) => {
|
||||||
const done = index < jsonBlockCount
|
const done = index < jsonBlockCount
|
||||||
const active = isStreaming && !done && index === jsonBlockCount
|
const active = isStreaming && !done && index === jsonBlockCount
|
||||||
return { label: s.title, done, active }
|
return { label: s.title, done, active, details: extractDetails(s.content) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,95 @@
|
||||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||||
import { Copy, Check, X } from 'lucide-react'
|
import { Copy, Check, Sparkles, Loader2, RotateCcw, Download, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvas-store'
|
import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
|
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
|
||||||
|
import { useAIStore } from '@/stores/ai-store'
|
||||||
|
import { streamChat } from '@/services/ai/ai-service'
|
||||||
import { generateReactCode } from '@/services/codegen/react-generator'
|
import { generateReactCode } from '@/services/codegen/react-generator'
|
||||||
import { generateHTMLCode } from '@/services/codegen/html-generator'
|
import { generateHTMLCode } from '@/services/codegen/html-generator'
|
||||||
|
import { generateVueCode } from '@/services/codegen/vue-generator'
|
||||||
|
import { generateSvelteCode } from '@/services/codegen/svelte-generator'
|
||||||
|
import { generateSwiftUICode } from '@/services/codegen/swiftui-generator'
|
||||||
|
import { generateComposeCode } from '@/services/codegen/compose-generator'
|
||||||
|
import { generateFlutterCode } from '@/services/codegen/flutter-generator'
|
||||||
|
import { generateReactNativeCode } from '@/services/codegen/react-native-generator'
|
||||||
import { generateCSSVariables } from '@/services/codegen/css-variables-generator'
|
import { generateCSSVariables } from '@/services/codegen/css-variables-generator'
|
||||||
import { highlightCode } from '@/utils/syntax-highlight'
|
import { highlightCode } from '@/utils/syntax-highlight'
|
||||||
import type { PenNode } from '@/types/pen'
|
import type { PenNode } from '@/types/pen'
|
||||||
|
|
||||||
type CodeTab = 'react' | 'html' | 'css-vars'
|
type CodeTab = 'react' | 'vue' | 'svelte' | 'html' | 'swiftui' | 'compose' | 'flutter' | 'react-native' | 'css-vars'
|
||||||
|
|
||||||
export default function CodePanel({ onClose }: { onClose: () => void }) {
|
const ENHANCE_SYSTEM_PROMPT = `You are a code rewriter. You receive auto-generated UI code and rewrite it to be idiomatic, production-ready, and RESPONSIVE.
|
||||||
|
|
||||||
|
CRITICAL: Your ENTIRE response must be ONLY the improved source code. Nothing else.
|
||||||
|
- Do NOT include explanations, commentary, reasoning, or thinking.
|
||||||
|
- Do NOT include markdown fences (\`\`\`), XML tags, or tool calls.
|
||||||
|
- Do NOT prefix with "Here is" or any preamble.
|
||||||
|
- Start your response with the first line of code and end with the last line.
|
||||||
|
|
||||||
|
Rewriting rules:
|
||||||
|
- Preserve visual fidelity — the output must look identical to the input design on desktop.
|
||||||
|
- Use semantic HTML where appropriate (nav, header, main, section, article, etc.).
|
||||||
|
- Replace absolute pixel positioning with proper layout (flexbox/grid) where possible.
|
||||||
|
- Use meaningful class/variable names derived from the node names.
|
||||||
|
- Keep the same framework and language as the input.
|
||||||
|
- For CSS-in-JS or scoped styles, keep styles co-located.
|
||||||
|
- For SwiftUI/Compose/Flutter, use idiomatic patterns (SwiftUI: adaptive stacks, Compose: adaptive layouts, Flutter: LayoutBuilder/MediaQuery).
|
||||||
|
- Do not add functionality, interactivity, or state beyond what exists.
|
||||||
|
- If design variables are present as var(--name), preserve them.
|
||||||
|
|
||||||
|
RESPONSIVE DESIGN (CRITICAL — make the output adapt gracefully across screen sizes):
|
||||||
|
- Convert fixed pixel widths to relative units (%, max-w-*, flex-1, w-full) where appropriate. Keep max-width constraints for readability.
|
||||||
|
- Use responsive Tailwind breakpoints (sm:, md:, lg:) for layout changes:
|
||||||
|
- Multi-column rows (horizontal layouts) should stack vertically on small screens (flex-col → sm:flex-row or md:flex-row).
|
||||||
|
- Grid layouts: use responsive column counts (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3).
|
||||||
|
- Adjust padding/gap with responsive variants (p-4 md:p-8 lg:p-12).
|
||||||
|
- Font sizes: scale down on mobile (text-2xl md:text-4xl lg:text-5xl).
|
||||||
|
- Images: use w-full max-w-* with aspect ratio preservation, never fixed px width.
|
||||||
|
- Container: use max-w-7xl mx-auto px-4 (or similar) for centered content with side padding.
|
||||||
|
- Navigation: consider collapsible patterns on small screens.
|
||||||
|
- For HTML+CSS: use @media queries with mobile-first breakpoints (min-width: 640px, 768px, 1024px).
|
||||||
|
- For Vue/Svelte: apply the same responsive CSS/class strategies.
|
||||||
|
- Do NOT break the design on desktop while making it responsive — desktop must remain visually faithful.`
|
||||||
|
|
||||||
|
/** Strip markdown fences, reasoning preamble, and tool-call XML from AI output. */
|
||||||
|
function cleanEnhancedResult(raw: string): string {
|
||||||
|
let result = raw
|
||||||
|
|
||||||
|
// Strip everything before the first code-like line if the AI prepended reasoning
|
||||||
|
// Detect patterns like "I'll start...", "<tool_call>", "Let me..."
|
||||||
|
const reasoningPatterns = /^(I['']ll |Let me |Here['']s |Sure|Okay|<tool_call>|<tool_name>)/im
|
||||||
|
if (reasoningPatterns.test(result)) {
|
||||||
|
// Try to find the start of actual code after the reasoning
|
||||||
|
// Look for common code markers: import, export, <, struct, @, class, fun, <!DOCTYPE
|
||||||
|
const codeStart = result.search(/^(import |export |<[!a-zA-Z]|struct |@Composable|@override|class |fun |<!DOCTYPE|package )/m)
|
||||||
|
if (codeStart > 0) {
|
||||||
|
result = result.slice(codeStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip markdown fences
|
||||||
|
if (result.startsWith('```')) {
|
||||||
|
const firstNewline = result.indexOf('\n')
|
||||||
|
const lastFence = result.lastIndexOf('```')
|
||||||
|
if (lastFence > firstNewline) {
|
||||||
|
result = result.slice(firstNewline + 1, lastFence).trimEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CodePanel() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [activeTab, setActiveTab] = useState<CodeTab>('react')
|
const [activeTab, setActiveTab] = useState<CodeTab>('react')
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [enhancedCode, setEnhancedCode] = useState<Record<string, string>>({})
|
||||||
|
const [isEnhancing, setIsEnhancing] = useState(false)
|
||||||
|
const enhanceAbortRef = useRef<AbortController | null>(null)
|
||||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||||
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
|
const selectedIds = useCanvasStore((s) => s.selection.selectedIds)
|
||||||
const activePageId = useCanvasStore((s) => s.activePageId)
|
const activePageId = useCanvasStore((s) => s.activePageId)
|
||||||
|
|
@ -38,107 +111,351 @@ export default function CodePanel({ onClose }: { onClose: () => void }) {
|
||||||
const document = useDocumentStore((s) => s.document)
|
const document = useDocumentStore((s) => s.document)
|
||||||
|
|
||||||
const generatedCode = useMemo(() => {
|
const generatedCode = useMemo(() => {
|
||||||
if (activeTab === 'css-vars') {
|
switch (activeTab) {
|
||||||
return generateCSSVariables(document)
|
case 'css-vars': return generateCSSVariables(document)
|
||||||
|
case 'react': return generateReactCode(targetNodes)
|
||||||
|
case 'vue': return generateVueCode(targetNodes)
|
||||||
|
case 'svelte': return generateSvelteCode(targetNodes)
|
||||||
|
case 'swiftui': return generateSwiftUICode(targetNodes)
|
||||||
|
case 'compose': return generateComposeCode(targetNodes)
|
||||||
|
case 'flutter': return generateFlutterCode(targetNodes)
|
||||||
|
case 'react-native': return generateReactNativeCode(targetNodes)
|
||||||
|
case 'html': {
|
||||||
|
const { html, css } = generateHTMLCode(targetNodes)
|
||||||
|
return `<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8" />\n <meta name="viewport" content="width=device-width, initial-scale=1.0" />\n <title>Design</title>\n <style>\n${css.split('\n').map((l) => ` ${l}`).join('\n')}\n </style>\n</head>\n<body>\n${html.split('\n').map((l) => ` ${l}`).join('\n')}\n</body>\n</html>`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (activeTab === 'react') {
|
|
||||||
return generateReactCode(targetNodes)
|
|
||||||
}
|
|
||||||
const { html, css } = generateHTMLCode(targetNodes)
|
|
||||||
return `<!-- HTML -->\n${html}\n\n/* CSS */\n${css}`
|
|
||||||
}, [activeTab, targetNodes, document])
|
}, [activeTab, targetNodes, document])
|
||||||
|
|
||||||
|
// Use enhanced code if available for this tab, otherwise the generated code
|
||||||
|
const displayCode = enhancedCode[activeTab] ?? generatedCode
|
||||||
|
|
||||||
const highlightedHTML = useMemo(() => {
|
const highlightedHTML = useMemo(() => {
|
||||||
if (activeTab === 'css-vars') {
|
const langMap: Record<CodeTab, Parameters<typeof highlightCode>[1]> = {
|
||||||
return highlightCode(generatedCode, 'css')
|
react: 'jsx',
|
||||||
|
vue: 'html',
|
||||||
|
svelte: 'html',
|
||||||
|
swiftui: 'swift',
|
||||||
|
compose: 'kotlin',
|
||||||
|
flutter: 'dart',
|
||||||
|
'react-native': 'jsx',
|
||||||
|
'css-vars': 'css',
|
||||||
|
html: 'html',
|
||||||
}
|
}
|
||||||
if (activeTab === 'react') {
|
// HTML / Vue / Svelte: split at <style to highlight CSS portion separately
|
||||||
return highlightCode(generatedCode, 'jsx')
|
if (activeTab === 'html' || activeTab === 'vue' || activeTab === 'svelte') {
|
||||||
|
const styleIdx = displayCode.indexOf('<style')
|
||||||
|
if (styleIdx !== -1) {
|
||||||
|
const templatePart = displayCode.slice(0, styleIdx)
|
||||||
|
const stylePart = displayCode.slice(styleIdx)
|
||||||
|
// Highlight the style tag line as HTML, then contents as CSS
|
||||||
|
const styleTagEnd = stylePart.indexOf('>\n')
|
||||||
|
if (styleTagEnd !== -1) {
|
||||||
|
const styleTag = stylePart.slice(0, styleTagEnd + 1)
|
||||||
|
const styleBody = stylePart.slice(styleTagEnd + 1)
|
||||||
|
const closingIdx = styleBody.lastIndexOf('</style>')
|
||||||
|
if (closingIdx !== -1) {
|
||||||
|
const cssContent = styleBody.slice(0, closingIdx)
|
||||||
|
const closingTag = styleBody.slice(closingIdx)
|
||||||
|
return (
|
||||||
|
highlightCode(templatePart, 'html') +
|
||||||
|
highlightCode(styleTag, 'html') + '\n' +
|
||||||
|
highlightCode(cssContent, 'css') +
|
||||||
|
highlightCode(closingTag, 'html')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highlightCode(templatePart, 'html') + highlightCode(stylePart, 'css')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Split HTML and CSS sections for mixed highlighting
|
return highlightCode(displayCode, langMap[activeTab])
|
||||||
const htmlEnd = generatedCode.indexOf('\n\n/* CSS */')
|
}, [activeTab, displayCode])
|
||||||
if (htmlEnd === -1) return highlightCode(generatedCode, 'html')
|
|
||||||
const htmlPart = generatedCode.slice(0, htmlEnd)
|
|
||||||
const cssPart = generatedCode.slice(htmlEnd + 2)
|
|
||||||
return highlightCode(htmlPart, 'html') + '\n\n' + highlightCode(cssPart, 'css')
|
|
||||||
}, [activeTab, generatedCode])
|
|
||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
navigator.clipboard.writeText(generatedCode).then(() => {
|
navigator.clipboard.writeText(displayCode).then(() => {
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
||||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000)
|
||||||
})
|
})
|
||||||
}, [generatedCode])
|
}, [displayCode])
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const extMap: Record<CodeTab, string> = {
|
||||||
|
react: 'tsx',
|
||||||
|
vue: 'vue',
|
||||||
|
svelte: 'svelte',
|
||||||
|
html: 'html',
|
||||||
|
swiftui: 'swift',
|
||||||
|
compose: 'kt',
|
||||||
|
flutter: 'dart',
|
||||||
|
'react-native': 'tsx',
|
||||||
|
'css-vars': 'css',
|
||||||
|
}
|
||||||
|
const ext = extMap[activeTab]
|
||||||
|
const blob = new Blob([displayCode], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = globalThis.document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `design.${ext}`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, [activeTab, displayCode])
|
||||||
|
|
||||||
|
const hasAI = useAIStore((s) => s.availableModels.length > 0)
|
||||||
|
|
||||||
|
const handleEnhance = useCallback(async () => {
|
||||||
|
if (isEnhancing || activeTab === 'css-vars') return
|
||||||
|
|
||||||
|
const model = useAIStore.getState().model
|
||||||
|
const modelGroups = useAIStore.getState().modelGroups
|
||||||
|
const provider = modelGroups.find((g) =>
|
||||||
|
g.models.some((m) => m.value === model),
|
||||||
|
)?.provider
|
||||||
|
|
||||||
|
if (!model || !provider) return
|
||||||
|
|
||||||
|
setIsEnhancing(true)
|
||||||
|
const abortController = new AbortController()
|
||||||
|
enhanceAbortRef.current = abortController
|
||||||
|
|
||||||
|
// Build a compact node summary for context
|
||||||
|
const nodesSummary = JSON.stringify(
|
||||||
|
targetNodes.map((n) => {
|
||||||
|
const base: Record<string, unknown> = { type: n.type, name: n.name }
|
||||||
|
if ('width' in n) base.width = n.width
|
||||||
|
if ('height' in n) base.height = n.height
|
||||||
|
if ('children' in n && Array.isArray(n.children)) base.childCount = n.children.length
|
||||||
|
if ('layout' in n) base.layout = n.layout
|
||||||
|
return base
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const frameworkNames: Record<CodeTab, string> = {
|
||||||
|
react: 'React with Tailwind CSS',
|
||||||
|
vue: 'Vue 3 SFC',
|
||||||
|
svelte: 'Svelte',
|
||||||
|
html: 'HTML + CSS',
|
||||||
|
swiftui: 'SwiftUI',
|
||||||
|
compose: 'Jetpack Compose (Kotlin)',
|
||||||
|
flutter: 'Flutter (Dart)',
|
||||||
|
'react-native': 'React Native',
|
||||||
|
'css-vars': '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = `Framework: ${frameworkNames[activeTab]}
|
||||||
|
|
||||||
|
Design nodes (JSON summary):
|
||||||
|
${nodesSummary}
|
||||||
|
|
||||||
|
Auto-generated code to improve:
|
||||||
|
${generatedCode}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = ''
|
||||||
|
for await (const chunk of streamChat(
|
||||||
|
ENHANCE_SYSTEM_PROMPT,
|
||||||
|
[{ role: 'user', content: userMessage }],
|
||||||
|
model,
|
||||||
|
{ thinkingMode: 'disabled', effort: 'high' },
|
||||||
|
provider,
|
||||||
|
abortController.signal,
|
||||||
|
)) {
|
||||||
|
if (chunk.type === 'text') {
|
||||||
|
result += chunk.content
|
||||||
|
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
|
||||||
|
}
|
||||||
|
if (chunk.type === 'error') break
|
||||||
|
}
|
||||||
|
// Clean up artifacts the AI may have included despite instructions
|
||||||
|
result = cleanEnhancedResult(result)
|
||||||
|
setEnhancedCode((prev) => ({ ...prev, [activeTab]: result }))
|
||||||
|
} finally {
|
||||||
|
setIsEnhancing(false)
|
||||||
|
enhanceAbortRef.current = null
|
||||||
|
}
|
||||||
|
}, [isEnhancing, activeTab, generatedCode, targetNodes])
|
||||||
|
|
||||||
|
const handleCancelEnhance = useCallback(() => {
|
||||||
|
enhanceAbortRef.current?.abort()
|
||||||
|
setIsEnhancing(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleResetEnhance = useCallback(() => {
|
||||||
|
setEnhancedCode((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[activeTab]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
|
// Clear enhanced code when nodes change
|
||||||
|
useEffect(() => {
|
||||||
|
setEnhancedCode({})
|
||||||
|
}, [targetNodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current)
|
||||||
|
enhanceAbortRef.current?.abort()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const tabs: { key: CodeTab; labelKey: string }[] = [
|
const tabs: { key: CodeTab; label: string }[] = [
|
||||||
{ key: 'react', labelKey: 'code.reactTailwind' },
|
{ key: 'react', label: 'React' },
|
||||||
{ key: 'html', labelKey: 'code.htmlCss' },
|
{ key: 'vue', label: 'Vue' },
|
||||||
{ key: 'css-vars', labelKey: 'code.cssVariables' },
|
{ key: 'svelte', label: 'Svelte' },
|
||||||
|
{ key: 'html', label: 'HTML' },
|
||||||
|
{ key: 'swiftui', label: 'SwiftUI' },
|
||||||
|
{ key: 'compose', label: 'Compose' },
|
||||||
|
{ key: 'flutter', label: 'Flutter' },
|
||||||
|
{ key: 'react-native', label: 'RN' },
|
||||||
|
{ key: 'css-vars', label: t('code.cssVariables') },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const tabsScrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||||
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||||
|
|
||||||
|
const updateScrollState = useCallback(() => {
|
||||||
|
const el = tabsScrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
setCanScrollLeft(el.scrollLeft > 1)
|
||||||
|
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = tabsScrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
updateScrollState()
|
||||||
|
el.addEventListener('scroll', updateScrollState, { passive: true })
|
||||||
|
const ro = new ResizeObserver(updateScrollState)
|
||||||
|
ro.observe(el)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('scroll', updateScrollState)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [updateScrollState])
|
||||||
|
|
||||||
|
const scrollTabs = useCallback((dir: 'left' | 'right') => {
|
||||||
|
tabsScrollRef.current?.scrollBy({ left: dir === 'left' ? -80 : 80, behavior: 'smooth' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-t border-border flex flex-col" style={{ height: 280 }}>
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Header */}
|
{/* Framework tabs + action buttons */}
|
||||||
<div className="h-8 flex items-center px-3 border-b border-border shrink-0">
|
<div className="flex items-center pl-1 pr-2 py-1 border-b border-border shrink-0 gap-0.5">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
{canScrollLeft && (
|
||||||
|
<button type="button" onClick={() => scrollTabs('left')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
|
||||||
|
<ChevronLeft size={10} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div ref={tabsScrollRef} className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto scrollbar-none px-1">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveTab(tab.key)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs px-2 py-1 rounded transition-colors',
|
'text-[10px] px-1.5 py-0.5 rounded transition-colors shrink-0 whitespace-nowrap',
|
||||||
activeTab === tab.key
|
activeTab === tab.key
|
||||||
? 'bg-secondary text-foreground'
|
? 'bg-secondary text-foreground'
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t(tab.labelKey)}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
{canScrollRight && (
|
||||||
<Button
|
<button type="button" onClick={() => scrollTabs('right')} className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground">
|
||||||
variant="ghost"
|
<ChevronRight size={10} />
|
||||||
size="icon-sm"
|
</button>
|
||||||
onClick={handleCopy}
|
)}
|
||||||
title={t('code.copyClipboard')}
|
<div className="flex items-center gap-0.5 shrink-0">
|
||||||
>
|
{hasAI && activeTab !== 'css-vars' && (
|
||||||
{copied ? <Check size={14} className="text-green-400" /> : <Copy size={14} />}
|
<>
|
||||||
</Button>
|
{enhancedCode[activeTab] && !isEnhancing && (
|
||||||
<Button
|
<Tooltip>
|
||||||
variant="ghost"
|
<TooltipTrigger asChild>
|
||||||
size="icon-sm"
|
<Button
|
||||||
onClick={onClose}
|
variant="ghost"
|
||||||
title={t('code.closeCodePanel')}
|
size="icon-sm"
|
||||||
>
|
onClick={handleResetEnhance}
|
||||||
<X size={14} />
|
className="text-muted-foreground h-5 w-5"
|
||||||
</Button>
|
>
|
||||||
|
<RotateCcw size={11} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('code.resetEnhance')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={isEnhancing ? handleCancelEnhance : handleEnhance}
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5',
|
||||||
|
isEnhancing && 'text-primary',
|
||||||
|
enhancedCode[activeTab] && !isEnhancing && 'text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isEnhancing ? <Loader2 size={12} className="animate-spin" /> : <Sparkles size={12} />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{isEnhancing ? t('code.cancelEnhance') : t('code.aiEnhance')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<Download size={12} />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('code.download')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{copied ? t('code.copied') : t('code.copyClipboard')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Code content */}
|
{/* Code content */}
|
||||||
<div className="flex-1 overflow-auto p-3">
|
<div className="flex-1 overflow-auto p-2">
|
||||||
<pre className="text-xs leading-relaxed font-mono text-foreground/80 whitespace-pre">
|
<pre className="text-[10px] leading-relaxed font-mono text-foreground/80 whitespace-pre-wrap break-all">
|
||||||
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
|
<code dangerouslySetInnerHTML={{ __html: highlightedHTML }} />
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* Footer info */}
|
||||||
<div className="h-6 flex items-center px-3 border-t border-border shrink-0">
|
<div className="h-5 flex items-center px-2 border-t border-border shrink-0">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[9px] text-muted-foreground">
|
||||||
{activeTab === 'css-vars'
|
{isEnhancing
|
||||||
? t('code.genCssVars')
|
? t('code.enhancing')
|
||||||
: selectedIds.length > 0
|
: enhancedCode[activeTab]
|
||||||
? t('code.genSelected', { count: selectedIds.length })
|
? t('code.enhanced')
|
||||||
: t('code.genDocument')}
|
: activeTab === 'css-vars'
|
||||||
|
? t('code.genCssVars')
|
||||||
|
: selectedIds.length > 0
|
||||||
|
? t('code.genSelected', { count: selectedIds.length })
|
||||||
|
: t('code.genDocument')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ import {
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Component,
|
Component,
|
||||||
Unlink,
|
Unlink,
|
||||||
|
SquaresUnite,
|
||||||
|
SquaresSubtract,
|
||||||
|
SquaresIntersect,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface LayerContextMenuProps {
|
interface LayerContextMenuProps {
|
||||||
|
|
@ -15,6 +18,7 @@ interface LayerContextMenuProps {
|
||||||
y: number
|
y: number
|
||||||
nodeId: string
|
nodeId: string
|
||||||
canGroup: boolean
|
canGroup: boolean
|
||||||
|
canBoolean: boolean
|
||||||
canCreateComponent: boolean
|
canCreateComponent: boolean
|
||||||
isReusable: boolean
|
isReusable: boolean
|
||||||
isInstance: boolean
|
isInstance: boolean
|
||||||
|
|
@ -26,6 +30,9 @@ const MENU_ITEMS = [
|
||||||
{ action: 'duplicate', labelKey: 'common.duplicate', icon: Copy },
|
{ action: 'duplicate', labelKey: 'common.duplicate', icon: Copy },
|
||||||
{ action: 'delete', labelKey: 'common.delete', icon: Trash2 },
|
{ action: 'delete', labelKey: 'common.delete', icon: Trash2 },
|
||||||
{ action: 'group', labelKey: 'layerMenu.groupSelection', icon: Group, requireGroup: true },
|
{ 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: 'make-component', labelKey: 'layerMenu.createComponent', icon: Component, requireCreateComponent: true },
|
||||||
{ action: 'detach-component', labelKey: 'layerMenu.detachComponent', icon: Unlink, requireReusable: true },
|
{ action: 'detach-component', labelKey: 'layerMenu.detachComponent', icon: Unlink, requireReusable: true },
|
||||||
{ action: 'detach-component', labelKey: 'layerMenu.detachInstance', icon: Unlink, requireInstance: true },
|
{ action: 'detach-component', labelKey: 'layerMenu.detachInstance', icon: Unlink, requireInstance: true },
|
||||||
|
|
@ -37,6 +44,7 @@ export default function LayerContextMenu({
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
canGroup,
|
canGroup,
|
||||||
|
canBoolean,
|
||||||
canCreateComponent,
|
canCreateComponent,
|
||||||
isReusable,
|
isReusable,
|
||||||
isInstance,
|
isInstance,
|
||||||
|
|
@ -72,6 +80,7 @@ export default function LayerContextMenu({
|
||||||
{MENU_ITEMS.filter(
|
{MENU_ITEMS.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
(!item.requireGroup || canGroup) &&
|
(!item.requireGroup || canGroup) &&
|
||||||
|
(!('requireBoolean' in item) || canBoolean) &&
|
||||||
(!('requireCreateComponent' in item) || canCreateComponent) &&
|
(!('requireCreateComponent' in item) || canCreateComponent) &&
|
||||||
(!('requireReusable' in item) || isReusable) &&
|
(!('requireReusable' in item) || isReusable) &&
|
||||||
(!('requireInstance' in item) || isInstance),
|
(!('requireInstance' in item) || isInstance),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
import { setSkipNextDepthResolve } from '@/canvas/use-canvas-selection'
|
import { setSkipNextDepthResolve } from '@/canvas/use-canvas-selection'
|
||||||
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
||||||
import type { PenNode } from '@/types/pen'
|
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 LayerItem from './layer-item'
|
||||||
import type { DropPosition } from './layer-item'
|
import type { DropPosition } from './layer-item'
|
||||||
import LayerContextMenu from './layer-context-menu'
|
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 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 {
|
interface DragState {
|
||||||
dragId: string | null
|
dragId: string | null
|
||||||
overId: string | null
|
overId: string | null
|
||||||
|
|
@ -121,6 +127,38 @@ function collectCollapsibleNodeIds(
|
||||||
|
|
||||||
export default function LayerPanel() {
|
export default function LayerPanel() {
|
||||||
const { t } = useTranslation()
|
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 activePageId = useCanvasStore((s) => s.activePageId)
|
||||||
const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId))
|
const children = useDocumentStore((s) => getActivePageChildren(s.document, activePageId))
|
||||||
const updateNode = useDocumentStore((s) => s.updateNode)
|
const updateNode = useDocumentStore((s) => s.updateNode)
|
||||||
|
|
@ -355,6 +393,24 @@ export default function LayerPanel() {
|
||||||
case 'detach-component':
|
case 'detach-component':
|
||||||
detachComponent(nodeId)
|
detachComponent(nodeId)
|
||||||
break
|
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)
|
setContextMenu(null)
|
||||||
},
|
},
|
||||||
|
|
@ -369,6 +425,7 @@ export default function LayerPanel() {
|
||||||
setSelection,
|
setSelection,
|
||||||
makeReusable,
|
makeReusable,
|
||||||
detachComponent,
|
detachComponent,
|
||||||
|
getNodeById,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -385,7 +442,12 @@ export default function LayerPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-56 bg-card border-r border-border flex flex-col shrink-0">
|
<div className="bg-card border-r border-border flex flex-col shrink-0 relative" style={{ width: panelWidth }}>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 z-10"
|
||||||
|
onMouseDown={handleResizeMouseDown}
|
||||||
|
/>
|
||||||
<PageTabs />
|
<PageTabs />
|
||||||
<div className="h-8 flex items-center px-3 border-b border-border">
|
<div className="h-8 flex items-center px-3 border-b border-border">
|
||||||
<span className="text-xs font-medium text-muted-foreground tracking-wider">
|
<span className="text-xs font-medium text-muted-foreground tracking-wider">
|
||||||
|
|
@ -420,12 +482,16 @@ export default function LayerPanel() {
|
||||||
? 'reusable' in contextNode && contextNode.reusable === true
|
? 'reusable' in contextNode && contextNode.reusable === true
|
||||||
: false
|
: false
|
||||||
const nodeIsInstance = contextNode?.type === 'ref'
|
const nodeIsInstance = contextNode?.type === 'ref'
|
||||||
|
const booleanNodes = selectedIds
|
||||||
|
.map((id) => getNodeById(id))
|
||||||
|
.filter((n): n is PenNode => n != null)
|
||||||
return (
|
return (
|
||||||
<LayerContextMenu
|
<LayerContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
nodeId={contextMenu.nodeId}
|
nodeId={contextMenu.nodeId}
|
||||||
canGroup={selectedIds.length >= 2}
|
canGroup={selectedIds.length >= 2}
|
||||||
|
canBoolean={canBooleanOp(booleanNodes)}
|
||||||
canCreateComponent={isContainer && !nodeIsReusable}
|
canCreateComponent={isContainer && !nodeIsReusable}
|
||||||
isReusable={nodeIsReusable}
|
isReusable={nodeIsReusable}
|
||||||
isInstance={nodeIsInstance}
|
isInstance={nodeIsInstance}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ const INSTANCE_DIRECT_PROPS = new Set([
|
||||||
'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme',
|
'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 { t } = useTranslation()
|
||||||
const activeId = useCanvasStore((s) => s.selection.activeId)
|
const activeId = useCanvasStore((s) => s.selection.activeId)
|
||||||
const setSelection = useCanvasStore((s) => s.setSelection)
|
const setSelection = useCanvasStore((s) => s.setSelection)
|
||||||
|
|
@ -158,10 +158,10 @@ export default function PropertyPanel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="h-8 flex items-center px-2 border-b border-border gap-1">
|
<div className="h-8 flex items-center px-2 border-b border-border gap-1 shrink-0">
|
||||||
{(nodeIsReusable || nodeIsInstance) && (
|
{(nodeIsReusable || nodeIsInstance) && (
|
||||||
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
|
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -364,6 +364,14 @@ export default function PropertyPanel() {
|
||||||
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
|
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return content
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
|
||||||
|
{content}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
src/components/panels/right-panel.tsx
Normal file
90
src/components/panels/right-panel.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="bg-card border-l border-border flex flex-col shrink-0 relative" style={{ width }}>
|
||||||
|
{/* Resize handle */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 z-10"
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="h-8 flex items-center px-2 border-b border-border shrink-0 gap-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(tab.key)}
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] font-medium px-2 py-0.5 rounded transition-colors',
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-secondary text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{activeTab === 'design' ? (
|
||||||
|
<PropertyPanel embedded />
|
||||||
|
) : (
|
||||||
|
<CodePanel />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -211,7 +211,7 @@ function ProviderRow({ type }: { type: AIProviderType }) {
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
'flex items-center gap-2.5 px-3 py-1.5 rounded-lg transition-colors',
|
||||||
provider.isConnected
|
provider.isConnected
|
||||||
? 'bg-secondary/40'
|
? 'bg-secondary/40'
|
||||||
: 'hover:bg-secondary/30',
|
: 'hover:bg-secondary/30',
|
||||||
|
|
@ -220,11 +220,11 @@ function ProviderRow({ type }: { type: AIProviderType }) {
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-7 h-7 rounded-md flex items-center justify-center shrink-0 transition-colors',
|
'w-6 h-6 rounded-md flex items-center justify-center shrink-0 transition-colors',
|
||||||
provider.isConnected ? 'bg-foreground/10 text-foreground' : 'bg-secondary text-muted-foreground',
|
provider.isConnected ? 'bg-foreground/10 text-foreground' : 'bg-secondary text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name + description */}
|
{/* Name + description */}
|
||||||
|
|
@ -305,7 +305,7 @@ export default function AgentSettingsDialog() {
|
||||||
const [mcpError, setMcpError] = useState<string | null>(null)
|
const [mcpError, setMcpError] = useState<string | null>(null)
|
||||||
const [mcpServerLoading, setMcpServerLoading] = useState(false)
|
const [mcpServerLoading, setMcpServerLoading] = useState(false)
|
||||||
const [mcpServerError, setMcpServerError] = useState<string | null>(null)
|
const [mcpServerError, setMcpServerError] = useState<string | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [configCopied, setConfigCopied] = useState(false)
|
||||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true)
|
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(true)
|
||||||
const [isElectron, setIsElectron] = useState(false)
|
const [isElectron, setIsElectron] = useState(false)
|
||||||
|
|
||||||
|
|
@ -364,11 +364,16 @@ export default function AgentSettingsDialog() {
|
||||||
}
|
}
|
||||||
}, [mcpServerRunning, mcpHttpPort, setMcpServerStatus, t])
|
}, [mcpServerRunning, mcpHttpPort, setMcpServerStatus, t])
|
||||||
|
|
||||||
const handleCopyLanUrl = useCallback(() => {
|
const handleCopyConfig = useCallback(() => {
|
||||||
if (!mcpServerLocalIp) return
|
if (!mcpServerLocalIp) return
|
||||||
navigator.clipboard.writeText(`http://${mcpServerLocalIp}:${mcpHttpPort}/mcp`)
|
const config = JSON.stringify(
|
||||||
setCopied(true)
|
{ type: 'http', url: `http://${mcpServerLocalIp}:${mcpHttpPort}/mcp` },
|
||||||
setTimeout(() => setCopied(false), 2000)
|
null,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
navigator.clipboard.writeText(config)
|
||||||
|
setConfigCopied(true)
|
||||||
|
setTimeout(() => setConfigCopied(false), 2000)
|
||||||
}, [mcpServerLocalIp, mcpHttpPort])
|
}, [mcpServerLocalIp, mcpHttpPort])
|
||||||
|
|
||||||
const handleToggleMCP = useCallback(
|
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"
|
className="relative bg-card rounded-xl border border-border w-[480px] max-h-[80vh] overflow-hidden shadow-xl flex flex-col"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-5 pt-4 pb-3">
|
<div className="flex items-center justify-between px-5 pt-3 pb-2">
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
{t('agents.title')}
|
{t('agents.title')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
@ -435,10 +440,10 @@ export default function AgentSettingsDialog() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 pb-5">
|
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||||
{/* Agents section */}
|
{/* Agents section */}
|
||||||
<div className="mb-5">
|
<div className="mb-3">
|
||||||
<div className="flex items-center gap-2 mb-2 px-1">
|
<div className="flex items-center gap-2 mb-1 px-1">
|
||||||
<Zap size={12} className="text-muted-foreground" />
|
<Zap size={12} className="text-muted-foreground" />
|
||||||
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t('agents.agentsOnCanvas')}
|
{t('agents.agentsOnCanvas')}
|
||||||
|
|
@ -453,17 +458,17 @@ export default function AgentSettingsDialog() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="h-px bg-border mb-5" />
|
<div className="h-px bg-border mb-3" />
|
||||||
|
|
||||||
{/* MCP Server section */}
|
{/* MCP Server section */}
|
||||||
<div className="mb-5">
|
<div className="mb-3">
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-1.5 px-1">
|
||||||
<Globe size={12} className="text-muted-foreground" />
|
<Globe size={12} className="text-muted-foreground" />
|
||||||
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t('agents.mcpServer')}
|
{t('agents.mcpServer')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-secondary/30">
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary/30">
|
||||||
{/* Status indicator */}
|
{/* Status indicator */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -507,19 +512,19 @@ export default function AgentSettingsDialog() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{mcpServerRunning && mcpServerLocalIp && (
|
{mcpServerRunning && mcpServerLocalIp && (
|
||||||
<div className="flex items-center gap-2 mt-2 px-3 py-2 rounded-lg bg-secondary/20">
|
<div className="mt-1.5 px-3 py-1.5 rounded-lg bg-secondary/20">
|
||||||
<span className="text-[10px] text-muted-foreground shrink-0">{t('agents.mcpLanAccess')}</span>
|
<div className="flex items-center justify-between">
|
||||||
<code className="text-[11px] text-foreground font-mono flex-1 truncate">
|
<span className="text-[10px] text-muted-foreground">{t('agents.mcpClientConfig')}</span>
|
||||||
http://{mcpServerLocalIp}:{mcpHttpPort}/mcp
|
<Button
|
||||||
</code>
|
variant="ghost"
|
||||||
<Button
|
size="icon-sm"
|
||||||
variant="ghost"
|
onClick={handleCopyConfig}
|
||||||
size="icon-sm"
|
className="shrink-0 h-5 w-5"
|
||||||
onClick={handleCopyLanUrl}
|
>
|
||||||
className="shrink-0 h-6 w-6"
|
{configCopied ? <Check size={9} className="text-green-500" /> : <Copy size={9} />}
|
||||||
>
|
</Button>
|
||||||
{copied ? <Check size={10} className="text-green-500" /> : <Copy size={10} />}
|
</div>
|
||||||
</Button>
|
<code className="text-[10px] text-muted-foreground font-mono select-all leading-none">{`{ "type": "http", "url": "http://${mcpServerLocalIp}:${mcpHttpPort}/mcp" }`}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mcpServerError && (
|
{mcpServerError && (
|
||||||
|
|
@ -531,23 +536,23 @@ export default function AgentSettingsDialog() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
<div className="h-px bg-border mb-5" />
|
<div className="h-px bg-border mb-3" />
|
||||||
|
|
||||||
{/* MCP integrations section */}
|
{/* MCP integrations section */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-1.5 px-1">
|
||||||
<Terminal size={12} className="text-muted-foreground" />
|
<Terminal size={12} className="text-muted-foreground" />
|
||||||
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
{t('agents.mcpIntegrations')}
|
{t('agents.mcpIntegrations')}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5">
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0">
|
||||||
{mcpIntegrations.map((m) => (
|
{mcpIntegrations.map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.tool}
|
key={m.tool}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between py-2 px-3 rounded-lg transition-colors',
|
'flex items-center justify-between py-1.5 px-3 rounded-lg transition-colors',
|
||||||
m.enabled ? 'bg-secondary/40' : 'hover:bg-secondary/20',
|
m.enabled ? 'bg-secondary/40' : 'hover:bg-secondary/20',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -579,7 +584,7 @@ export default function AgentSettingsDialog() {
|
||||||
<p className="text-[10px] text-destructive">{mcpError}</p>
|
<p className="text-[10px] text-destructive">{mcpError}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-[10px] text-muted-foreground/60 mt-3 px-1">
|
<p className="text-[10px] text-muted-foreground/60 mt-2 px-1">
|
||||||
{t('agents.mcpRestart')}
|
{t('agents.mcpRestart')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -587,7 +592,7 @@ export default function AgentSettingsDialog() {
|
||||||
{/* Auto-update toggle (Electron only) */}
|
{/* Auto-update toggle (Electron only) */}
|
||||||
{isElectron && (
|
{isElectron && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-5" />
|
<div className="h-px bg-border my-3" />
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RefreshCw size={12} className="text-muted-foreground" />
|
<RefreshCw size={12} className="text-muted-foreground" />
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useDocumentStore } from '@/stores/document-store'
|
||||||
import { useHistoryStore } from '@/stores/history-store'
|
import { useHistoryStore } from '@/stores/history-store'
|
||||||
import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
|
import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
|
||||||
import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync'
|
import { syncCanvasPositionsToStore } from '@/canvas/use-canvas-sync'
|
||||||
|
import { normalizePenDocument } from '@/utils/normalize-pen-file'
|
||||||
import {
|
import {
|
||||||
supportsFileSystemAccess,
|
supportsFileSystemAccess,
|
||||||
writeToFileHandle,
|
writeToFileHandle,
|
||||||
|
|
@ -22,6 +23,29 @@ export function useElectronMenu() {
|
||||||
const api = window.electronAPI
|
const api = window.electronAPI
|
||||||
if (!api?.onMenuAction) return
|
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) => {
|
const cleanup = api.onMenuAction((action: string) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'new':
|
case 'new':
|
||||||
|
|
@ -113,6 +137,9 @@ export function useElectronMenu() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return cleanup
|
return () => {
|
||||||
|
cleanup()
|
||||||
|
cleanupOpenFile?.()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
|
import { useDocumentStore, getActivePageChildren } from '@/stores/document-store'
|
||||||
import { useHistoryStore } from '@/stores/history-store'
|
import { useHistoryStore } from '@/stores/history-store'
|
||||||
import { cloneNodesWithNewIds } from '@/utils/node-clone'
|
import { cloneNodesWithNewIds } from '@/utils/node-clone'
|
||||||
|
import { canBooleanOp, executeBooleanOp, type BooleanOpType } from '@/utils/boolean-ops'
|
||||||
import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste'
|
import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste'
|
||||||
import {
|
import {
|
||||||
supportsFileSystemAccess,
|
supportsFileSystemAccess,
|
||||||
|
|
@ -267,6 +268,40 @@ export function useKeyboardShortcuts() {
|
||||||
return
|
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<string, BooleanOpType> = {
|
||||||
|
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<typeof n> => 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)
|
// Tool shortcuts (single key, no modifier)
|
||||||
if (!isMod && !e.shiftKey && !e.altKey) {
|
if (!isMod && !e.shiftKey && !e.altKey) {
|
||||||
const tool = TOOL_KEYS[e.key.toLowerCase()]
|
const tool = TOOL_KEYS[e.key.toLowerCase()]
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const de: TranslationKeys = {
|
||||||
'topbar.open': 'Öffnen',
|
'topbar.open': 'Öffnen',
|
||||||
'topbar.save': 'Speichern',
|
'topbar.save': 'Speichern',
|
||||||
'topbar.importFigma': 'Figma importieren',
|
'topbar.importFigma': 'Figma importieren',
|
||||||
|
'topbar.codePanel': 'Code',
|
||||||
'topbar.lightMode': 'Heller Modus',
|
'topbar.lightMode': 'Heller Modus',
|
||||||
'topbar.darkMode': 'Dunkler Modus',
|
'topbar.darkMode': 'Dunkler Modus',
|
||||||
'topbar.fullscreen': 'Vollbild',
|
'topbar.fullscreen': 'Vollbild',
|
||||||
|
|
@ -54,6 +55,11 @@ const de: TranslationKeys = {
|
||||||
'topbar.connected': 'verbunden',
|
'topbar.connected': 'verbunden',
|
||||||
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Design',
|
||||||
|
'rightPanel.code': 'Code',
|
||||||
|
'rightPanel.noSelection': 'Element auswählen',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Seiten',
|
'pages.title': 'Seiten',
|
||||||
'pages.addPage': 'Seite hinzufügen',
|
'pages.addPage': 'Seite hinzufügen',
|
||||||
|
|
@ -107,6 +113,9 @@ const de: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Komponente erstellen',
|
'layerMenu.createComponent': 'Komponente erstellen',
|
||||||
'layerMenu.detachComponent': 'Komponente lösen',
|
'layerMenu.detachComponent': 'Komponente lösen',
|
||||||
'layerMenu.detachInstance': 'Instanz lösen',
|
'layerMenu.detachInstance': 'Instanz lösen',
|
||||||
|
'layerMenu.booleanUnion': 'Vereinigung',
|
||||||
|
'layerMenu.booleanSubtract': 'Subtraktion',
|
||||||
|
'layerMenu.booleanIntersect': 'Schnittmenge',
|
||||||
'layerMenu.toggleLock': 'Sperren umschalten',
|
'layerMenu.toggleLock': 'Sperren umschalten',
|
||||||
'layerMenu.toggleVisibility': 'Sichtbarkeit umschalten',
|
'layerMenu.toggleVisibility': 'Sichtbarkeit umschalten',
|
||||||
|
|
||||||
|
|
@ -266,11 +275,18 @@ const de: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'In Zwischenablage kopieren',
|
'code.copyClipboard': 'In Zwischenablage kopieren',
|
||||||
|
'code.copied': 'Kopiert!',
|
||||||
|
'code.download': 'Code-Datei herunterladen',
|
||||||
'code.closeCodePanel': 'Code-Panel schließen',
|
'code.closeCodePanel': 'Code-Panel schließen',
|
||||||
'code.genCssVars': 'CSS-Variablen für das gesamte Dokument generieren',
|
'code.genCssVars': 'CSS-Variablen für das gesamte Dokument generieren',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Code für {{count}} ausgewählte(s) Element(e) generieren',
|
'Code für {{count}} ausgewählte(s) Element(e) generieren',
|
||||||
'code.genDocument': 'Code für das gesamte Dokument 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 Dialog ──
|
||||||
'save.saveAs': 'Speichern unter',
|
'save.saveAs': 'Speichern unter',
|
||||||
|
|
@ -305,6 +321,7 @@ const de: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Läuft',
|
'agents.mcpServerRunning': 'Läuft',
|
||||||
'agents.mcpServerStopped': 'Gestoppt',
|
'agents.mcpServerStopped': 'Gestoppt',
|
||||||
'agents.mcpLanAccess': 'LAN-Zugriff',
|
'agents.mcpLanAccess': 'LAN-Zugriff',
|
||||||
|
'agents.mcpClientConfig': 'Client-Konfiguration',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const en = {
|
||||||
'topbar.open': 'Open',
|
'topbar.open': 'Open',
|
||||||
'topbar.save': 'Save',
|
'topbar.save': 'Save',
|
||||||
'topbar.importFigma': 'Import Figma',
|
'topbar.importFigma': 'Import Figma',
|
||||||
|
'topbar.codePanel': 'Code',
|
||||||
'topbar.lightMode': 'Light mode',
|
'topbar.lightMode': 'Light mode',
|
||||||
'topbar.darkMode': 'Dark mode',
|
'topbar.darkMode': 'Dark mode',
|
||||||
'topbar.fullscreen': 'Fullscreen',
|
'topbar.fullscreen': 'Fullscreen',
|
||||||
|
|
@ -52,6 +53,11 @@ const en = {
|
||||||
'topbar.connected': 'connected',
|
'topbar.connected': 'connected',
|
||||||
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Design',
|
||||||
|
'rightPanel.code': 'Code',
|
||||||
|
'rightPanel.noSelection': 'Select an element',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Pages',
|
'pages.title': 'Pages',
|
||||||
'pages.addPage': 'Add page',
|
'pages.addPage': 'Add page',
|
||||||
|
|
@ -103,6 +109,9 @@ const en = {
|
||||||
'layerMenu.createComponent': 'Create Component',
|
'layerMenu.createComponent': 'Create Component',
|
||||||
'layerMenu.detachComponent': 'Detach Component',
|
'layerMenu.detachComponent': 'Detach Component',
|
||||||
'layerMenu.detachInstance': 'Detach Instance',
|
'layerMenu.detachInstance': 'Detach Instance',
|
||||||
|
'layerMenu.booleanUnion': 'Union',
|
||||||
|
'layerMenu.booleanSubtract': 'Subtract',
|
||||||
|
'layerMenu.booleanIntersect': 'Intersect',
|
||||||
'layerMenu.toggleLock': 'Toggle Lock',
|
'layerMenu.toggleLock': 'Toggle Lock',
|
||||||
'layerMenu.toggleVisibility': 'Toggle Visibility',
|
'layerMenu.toggleVisibility': 'Toggle Visibility',
|
||||||
|
|
||||||
|
|
@ -262,11 +271,18 @@ const en = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Copy to clipboard',
|
'code.copyClipboard': 'Copy to clipboard',
|
||||||
|
'code.copied': 'Copied!',
|
||||||
|
'code.download': 'Download code file',
|
||||||
'code.closeCodePanel': 'Close code panel',
|
'code.closeCodePanel': 'Close code panel',
|
||||||
'code.genCssVars': 'Generating CSS variables for entire document',
|
'code.genCssVars': 'Generating CSS variables for entire document',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Generating code for {{count}} selected element(s)',
|
'Generating code for {{count}} selected element(s)',
|
||||||
'code.genDocument': 'Generating code for entire document',
|
'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 Dialog ──
|
||||||
'save.saveAs': 'Save As',
|
'save.saveAs': 'Save As',
|
||||||
|
|
@ -301,6 +317,7 @@ const en = {
|
||||||
'agents.mcpServerRunning': 'Running',
|
'agents.mcpServerRunning': 'Running',
|
||||||
'agents.mcpServerStopped': 'Stopped',
|
'agents.mcpServerStopped': 'Stopped',
|
||||||
'agents.mcpLanAccess': 'LAN Access',
|
'agents.mcpLanAccess': 'LAN Access',
|
||||||
|
'agents.mcpClientConfig': 'Client Config',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const es: TranslationKeys = {
|
||||||
'topbar.open': 'Abrir',
|
'topbar.open': 'Abrir',
|
||||||
'topbar.save': 'Guardar',
|
'topbar.save': 'Guardar',
|
||||||
'topbar.importFigma': 'Importar Figma',
|
'topbar.importFigma': 'Importar Figma',
|
||||||
|
'topbar.codePanel': 'Código',
|
||||||
'topbar.lightMode': 'Modo claro',
|
'topbar.lightMode': 'Modo claro',
|
||||||
'topbar.darkMode': 'Modo oscuro',
|
'topbar.darkMode': 'Modo oscuro',
|
||||||
'topbar.fullscreen': 'Pantalla completa',
|
'topbar.fullscreen': 'Pantalla completa',
|
||||||
|
|
@ -54,6 +55,11 @@ const es: TranslationKeys = {
|
||||||
'topbar.connected': 'conectado',
|
'topbar.connected': 'conectado',
|
||||||
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Diseño',
|
||||||
|
'rightPanel.code': 'Código',
|
||||||
|
'rightPanel.noSelection': 'Selecciona un elemento',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Páginas',
|
'pages.title': 'Páginas',
|
||||||
'pages.addPage': 'Agregar página',
|
'pages.addPage': 'Agregar página',
|
||||||
|
|
@ -107,6 +113,9 @@ const es: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Crear componente',
|
'layerMenu.createComponent': 'Crear componente',
|
||||||
'layerMenu.detachComponent': 'Separar componente',
|
'layerMenu.detachComponent': 'Separar componente',
|
||||||
'layerMenu.detachInstance': 'Separar instancia',
|
'layerMenu.detachInstance': 'Separar instancia',
|
||||||
|
'layerMenu.booleanUnion': 'Unión',
|
||||||
|
'layerMenu.booleanSubtract': 'Restar',
|
||||||
|
'layerMenu.booleanIntersect': 'Intersección',
|
||||||
'layerMenu.toggleLock': 'Alternar bloqueo',
|
'layerMenu.toggleLock': 'Alternar bloqueo',
|
||||||
'layerMenu.toggleVisibility': 'Alternar visibilidad',
|
'layerMenu.toggleVisibility': 'Alternar visibilidad',
|
||||||
|
|
||||||
|
|
@ -270,12 +279,19 @@ const es: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Copiar al portapapeles',
|
'code.copyClipboard': 'Copiar al portapapeles',
|
||||||
|
'code.copied': '¡Copiado!',
|
||||||
|
'code.download': 'Descargar archivo de código',
|
||||||
'code.closeCodePanel': 'Cerrar panel de código',
|
'code.closeCodePanel': 'Cerrar panel de código',
|
||||||
'code.genCssVars':
|
'code.genCssVars':
|
||||||
'Generando variables CSS para todo el documento',
|
'Generando variables CSS para todo el documento',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Generando código para {{count}} elemento(s) seleccionado(s)',
|
'Generando código para {{count}} elemento(s) seleccionado(s)',
|
||||||
'code.genDocument': 'Generando código para todo el documento',
|
'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 Dialog ──
|
||||||
'save.saveAs': 'Guardar como',
|
'save.saveAs': 'Guardar como',
|
||||||
|
|
@ -310,6 +326,7 @@ const es: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'En ejecución',
|
'agents.mcpServerRunning': 'En ejecución',
|
||||||
'agents.mcpServerStopped': 'Detenido',
|
'agents.mcpServerStopped': 'Detenido',
|
||||||
'agents.mcpLanAccess': 'Acceso LAN',
|
'agents.mcpLanAccess': 'Acceso LAN',
|
||||||
|
'agents.mcpClientConfig': 'Config. del cliente',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const fr: TranslationKeys = {
|
||||||
'topbar.open': 'Ouvrir',
|
'topbar.open': 'Ouvrir',
|
||||||
'topbar.save': 'Enregistrer',
|
'topbar.save': 'Enregistrer',
|
||||||
'topbar.importFigma': 'Importer Figma',
|
'topbar.importFigma': 'Importer Figma',
|
||||||
|
'topbar.codePanel': 'Code',
|
||||||
'topbar.lightMode': 'Mode clair',
|
'topbar.lightMode': 'Mode clair',
|
||||||
'topbar.darkMode': 'Mode sombre',
|
'topbar.darkMode': 'Mode sombre',
|
||||||
'topbar.fullscreen': 'Plein écran',
|
'topbar.fullscreen': 'Plein écran',
|
||||||
|
|
@ -54,6 +55,11 @@ const fr: TranslationKeys = {
|
||||||
'topbar.connected': 'connecté',
|
'topbar.connected': 'connecté',
|
||||||
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Design',
|
||||||
|
'rightPanel.code': 'Code',
|
||||||
|
'rightPanel.noSelection': 'Sélectionnez un élément',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Pages',
|
'pages.title': 'Pages',
|
||||||
'pages.addPage': 'Ajouter une page',
|
'pages.addPage': 'Ajouter une page',
|
||||||
|
|
@ -107,6 +113,9 @@ const fr: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Créer un composant',
|
'layerMenu.createComponent': 'Créer un composant',
|
||||||
'layerMenu.detachComponent': 'Détacher le composant',
|
'layerMenu.detachComponent': 'Détacher le composant',
|
||||||
'layerMenu.detachInstance': 'Détacher l\u2019instance',
|
'layerMenu.detachInstance': 'Détacher l\u2019instance',
|
||||||
|
'layerMenu.booleanUnion': 'Union',
|
||||||
|
'layerMenu.booleanSubtract': 'Soustraire',
|
||||||
|
'layerMenu.booleanIntersect': 'Intersection',
|
||||||
'layerMenu.toggleLock': 'Verrouiller / Déverrouiller',
|
'layerMenu.toggleLock': 'Verrouiller / Déverrouiller',
|
||||||
'layerMenu.toggleVisibility': 'Afficher / Masquer',
|
'layerMenu.toggleVisibility': 'Afficher / Masquer',
|
||||||
|
|
||||||
|
|
@ -268,12 +277,19 @@ const fr: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Copier dans le presse-papiers',
|
'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.closeCodePanel': 'Fermer le panneau de code',
|
||||||
'code.genCssVars':
|
'code.genCssVars':
|
||||||
'Génération des variables CSS pour l\u2019ensemble du document',
|
'Génération des variables CSS pour l\u2019ensemble du document',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Génération du code pour {{count}} élément(s) sélectionné(s)',
|
'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.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 Dialog ──
|
||||||
'save.saveAs': 'Enregistrer sous',
|
'save.saveAs': 'Enregistrer sous',
|
||||||
|
|
@ -308,6 +324,7 @@ const fr: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'En cours',
|
'agents.mcpServerRunning': 'En cours',
|
||||||
'agents.mcpServerStopped': 'Arrêté',
|
'agents.mcpServerStopped': 'Arrêté',
|
||||||
'agents.mcpLanAccess': 'Accès LAN',
|
'agents.mcpLanAccess': 'Accès LAN',
|
||||||
|
'agents.mcpClientConfig': 'Config. client',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const hi: TranslationKeys = {
|
||||||
'topbar.open': 'खोलें',
|
'topbar.open': 'खोलें',
|
||||||
'topbar.save': 'सहेजें',
|
'topbar.save': 'सहेजें',
|
||||||
'topbar.importFigma': 'Figma आयात करें',
|
'topbar.importFigma': 'Figma आयात करें',
|
||||||
|
'topbar.codePanel': 'कोड',
|
||||||
'topbar.lightMode': 'लाइट मोड',
|
'topbar.lightMode': 'लाइट मोड',
|
||||||
'topbar.darkMode': 'डार्क मोड',
|
'topbar.darkMode': 'डार्क मोड',
|
||||||
'topbar.fullscreen': 'पूर्ण स्क्रीन',
|
'topbar.fullscreen': 'पूर्ण स्क्रीन',
|
||||||
|
|
@ -54,6 +55,11 @@ const hi: TranslationKeys = {
|
||||||
'topbar.connected': 'कनेक्टेड',
|
'topbar.connected': 'कनेक्टेड',
|
||||||
'topbar.agentStatus': '{{agents}} एजेंट{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} एजेंट{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'डिज़ाइन',
|
||||||
|
'rightPanel.code': 'कोड',
|
||||||
|
'rightPanel.noSelection': 'एक तत्व चुनें',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'पेज',
|
'pages.title': 'पेज',
|
||||||
'pages.addPage': 'पेज जोड़ें',
|
'pages.addPage': 'पेज जोड़ें',
|
||||||
|
|
@ -105,6 +111,9 @@ const hi: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'कंपोनेंट बनाएँ',
|
'layerMenu.createComponent': 'कंपोनेंट बनाएँ',
|
||||||
'layerMenu.detachComponent': 'कंपोनेंट अलग करें',
|
'layerMenu.detachComponent': 'कंपोनेंट अलग करें',
|
||||||
'layerMenu.detachInstance': 'इंस्टेंस अलग करें',
|
'layerMenu.detachInstance': 'इंस्टेंस अलग करें',
|
||||||
|
'layerMenu.booleanUnion': 'संयोजन',
|
||||||
|
'layerMenu.booleanSubtract': 'घटाना',
|
||||||
|
'layerMenu.booleanIntersect': 'प्रतिच्छेदन',
|
||||||
'layerMenu.toggleLock': 'लॉक टॉगल करें',
|
'layerMenu.toggleLock': 'लॉक टॉगल करें',
|
||||||
'layerMenu.toggleVisibility': 'दृश्यता टॉगल करें',
|
'layerMenu.toggleVisibility': 'दृश्यता टॉगल करें',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const hi: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'क्लिपबोर्ड पर कॉपी करें',
|
'code.copyClipboard': 'क्लिपबोर्ड पर कॉपी करें',
|
||||||
|
'code.copied': 'कॉपी हो गया!',
|
||||||
|
'code.download': 'कोड फ़ाइल डाउनलोड करें',
|
||||||
'code.closeCodePanel': 'कोड पैनल बंद करें',
|
'code.closeCodePanel': 'कोड पैनल बंद करें',
|
||||||
'code.genCssVars': 'संपूर्ण डॉक्यूमेंट के लिए CSS वेरिएबल जनरेट हो रहे हैं',
|
'code.genCssVars': 'संपूर्ण डॉक्यूमेंट के लिए CSS वेरिएबल जनरेट हो रहे हैं',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'{{count}} चयनित तत्व(ओं) के लिए कोड जनरेट हो रहा है',
|
'{{count}} चयनित तत्व(ओं) के लिए कोड जनरेट हो रहा है',
|
||||||
'code.genDocument': 'संपूर्ण डॉक्यूमेंट के लिए कोड जनरेट हो रहा है',
|
'code.genDocument': 'संपूर्ण डॉक्यूमेंट के लिए कोड जनरेट हो रहा है',
|
||||||
|
'code.aiEnhance': 'AI से सुधारें',
|
||||||
|
'code.cancelEnhance': 'सुधार रद्द करें',
|
||||||
|
'code.resetEnhance': 'मूल पर वापस जाएं',
|
||||||
|
'code.enhancing': 'AI कोड सुधार रहा है...',
|
||||||
|
'code.enhanced': 'AI द्वारा सुधारा गया',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': 'इस रूप में सहेजें',
|
'save.saveAs': 'इस रूप में सहेजें',
|
||||||
|
|
@ -303,6 +319,7 @@ const hi: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'चल रहा है',
|
'agents.mcpServerRunning': 'चल रहा है',
|
||||||
'agents.mcpServerStopped': 'रुका हुआ',
|
'agents.mcpServerStopped': 'रुका हुआ',
|
||||||
'agents.mcpLanAccess': 'LAN एक्सेस',
|
'agents.mcpLanAccess': 'LAN एक्सेस',
|
||||||
|
'agents.mcpClientConfig': 'क्लाइंट कॉन्फ़िग',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const id: TranslationKeys = {
|
||||||
'topbar.open': 'Buka',
|
'topbar.open': 'Buka',
|
||||||
'topbar.save': 'Simpan',
|
'topbar.save': 'Simpan',
|
||||||
'topbar.importFigma': 'Impor Figma',
|
'topbar.importFigma': 'Impor Figma',
|
||||||
|
'topbar.codePanel': 'Kode',
|
||||||
'topbar.lightMode': 'Mode terang',
|
'topbar.lightMode': 'Mode terang',
|
||||||
'topbar.darkMode': 'Mode gelap',
|
'topbar.darkMode': 'Mode gelap',
|
||||||
'topbar.fullscreen': 'Layar penuh',
|
'topbar.fullscreen': 'Layar penuh',
|
||||||
|
|
@ -54,6 +55,11 @@ const id: TranslationKeys = {
|
||||||
'topbar.connected': 'terhubung',
|
'topbar.connected': 'terhubung',
|
||||||
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Desain',
|
||||||
|
'rightPanel.code': 'Kode',
|
||||||
|
'rightPanel.noSelection': 'Pilih sebuah elemen',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Halaman',
|
'pages.title': 'Halaman',
|
||||||
'pages.addPage': 'Tambah halaman',
|
'pages.addPage': 'Tambah halaman',
|
||||||
|
|
@ -105,6 +111,9 @@ const id: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Buat Komponen',
|
'layerMenu.createComponent': 'Buat Komponen',
|
||||||
'layerMenu.detachComponent': 'Lepaskan Komponen',
|
'layerMenu.detachComponent': 'Lepaskan Komponen',
|
||||||
'layerMenu.detachInstance': 'Lepaskan Instance',
|
'layerMenu.detachInstance': 'Lepaskan Instance',
|
||||||
|
'layerMenu.booleanUnion': 'Gabungan',
|
||||||
|
'layerMenu.booleanSubtract': 'Kurangi',
|
||||||
|
'layerMenu.booleanIntersect': 'Irisan',
|
||||||
'layerMenu.toggleLock': 'Alihkan Kunci',
|
'layerMenu.toggleLock': 'Alihkan Kunci',
|
||||||
'layerMenu.toggleVisibility': 'Alihkan Visibilitas',
|
'layerMenu.toggleVisibility': 'Alihkan Visibilitas',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const id: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Salin ke papan klip',
|
'code.copyClipboard': 'Salin ke papan klip',
|
||||||
|
'code.copied': 'Tersalin!',
|
||||||
|
'code.download': 'Unduh file kode',
|
||||||
'code.closeCodePanel': 'Tutup panel kode',
|
'code.closeCodePanel': 'Tutup panel kode',
|
||||||
'code.genCssVars': 'Membuat CSS variables untuk seluruh dokumen',
|
'code.genCssVars': 'Membuat CSS variables untuk seluruh dokumen',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Membuat kode untuk {{count}} elemen yang dipilih',
|
'Membuat kode untuk {{count}} elemen yang dipilih',
|
||||||
'code.genDocument': 'Membuat kode untuk seluruh dokumen',
|
'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 Dialog ──
|
||||||
'save.saveAs': 'Simpan Sebagai',
|
'save.saveAs': 'Simpan Sebagai',
|
||||||
|
|
@ -303,6 +319,7 @@ const id: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Berjalan',
|
'agents.mcpServerRunning': 'Berjalan',
|
||||||
'agents.mcpServerStopped': 'Berhenti',
|
'agents.mcpServerStopped': 'Berhenti',
|
||||||
'agents.mcpLanAccess': 'Akses LAN',
|
'agents.mcpLanAccess': 'Akses LAN',
|
||||||
|
'agents.mcpClientConfig': 'Konfigurasi klien',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const ja: TranslationKeys = {
|
||||||
'topbar.open': '開く',
|
'topbar.open': '開く',
|
||||||
'topbar.save': '保存',
|
'topbar.save': '保存',
|
||||||
'topbar.importFigma': 'Figma をインポート',
|
'topbar.importFigma': 'Figma をインポート',
|
||||||
|
'topbar.codePanel': 'コード',
|
||||||
'topbar.lightMode': 'ライトモード',
|
'topbar.lightMode': 'ライトモード',
|
||||||
'topbar.darkMode': 'ダークモード',
|
'topbar.darkMode': 'ダークモード',
|
||||||
'topbar.fullscreen': 'フルスクリーン',
|
'topbar.fullscreen': 'フルスクリーン',
|
||||||
|
|
@ -54,6 +55,11 @@ const ja: TranslationKeys = {
|
||||||
'topbar.connected': '接続済み',
|
'topbar.connected': '接続済み',
|
||||||
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} Agent{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'デザイン',
|
||||||
|
'rightPanel.code': 'コード',
|
||||||
|
'rightPanel.noSelection': '要素を選択してください',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'ページ',
|
'pages.title': 'ページ',
|
||||||
'pages.addPage': 'ページを追加',
|
'pages.addPage': 'ページを追加',
|
||||||
|
|
@ -109,6 +115,9 @@ const ja: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'コンポーネントを作成',
|
'layerMenu.createComponent': 'コンポーネントを作成',
|
||||||
'layerMenu.detachComponent': 'コンポーネントを解除',
|
'layerMenu.detachComponent': 'コンポーネントを解除',
|
||||||
'layerMenu.detachInstance': 'インスタンスを解除',
|
'layerMenu.detachInstance': 'インスタンスを解除',
|
||||||
|
'layerMenu.booleanUnion': '合体',
|
||||||
|
'layerMenu.booleanSubtract': '前面で型抜き',
|
||||||
|
'layerMenu.booleanIntersect': '交差',
|
||||||
'layerMenu.toggleLock': 'ロックの切り替え',
|
'layerMenu.toggleLock': 'ロックの切り替え',
|
||||||
'layerMenu.toggleVisibility': '表示の切り替え',
|
'layerMenu.toggleVisibility': '表示の切り替え',
|
||||||
|
|
||||||
|
|
@ -268,10 +277,17 @@ const ja: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'クリップボードにコピー',
|
'code.copyClipboard': 'クリップボードにコピー',
|
||||||
|
'code.copied': 'コピーしました!',
|
||||||
|
'code.download': 'コードファイルをダウンロード',
|
||||||
'code.closeCodePanel': 'コードパネルを閉じる',
|
'code.closeCodePanel': 'コードパネルを閉じる',
|
||||||
'code.genCssVars': 'ドキュメント全体の CSS 変数を生成中',
|
'code.genCssVars': 'ドキュメント全体の CSS 変数を生成中',
|
||||||
'code.genSelected': '{{count}} 個の選択要素のコードを生成中',
|
'code.genSelected': '{{count}} 個の選択要素のコードを生成中',
|
||||||
'code.genDocument': 'ドキュメント全体のコードを生成中',
|
'code.genDocument': 'ドキュメント全体のコードを生成中',
|
||||||
|
'code.aiEnhance': 'AI で改善',
|
||||||
|
'code.cancelEnhance': '改善をキャンセル',
|
||||||
|
'code.resetEnhance': '元に戻す',
|
||||||
|
'code.enhancing': 'AI がコードを改善中...',
|
||||||
|
'code.enhanced': 'AI により改善済み',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': '名前を付けて保存',
|
'save.saveAs': '名前を付けて保存',
|
||||||
|
|
@ -306,6 +322,7 @@ const ja: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': '実行中',
|
'agents.mcpServerRunning': '実行中',
|
||||||
'agents.mcpServerStopped': '停止中',
|
'agents.mcpServerStopped': '停止中',
|
||||||
'agents.mcpLanAccess': 'LAN アクセス',
|
'agents.mcpLanAccess': 'LAN アクセス',
|
||||||
|
'agents.mcpClientConfig': 'クライアント設定',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const ko: TranslationKeys = {
|
||||||
'topbar.open': '열기',
|
'topbar.open': '열기',
|
||||||
'topbar.save': '저장',
|
'topbar.save': '저장',
|
||||||
'topbar.importFigma': 'Figma 가져오기',
|
'topbar.importFigma': 'Figma 가져오기',
|
||||||
|
'topbar.codePanel': '코드',
|
||||||
'topbar.lightMode': '라이트 모드',
|
'topbar.lightMode': '라이트 모드',
|
||||||
'topbar.darkMode': '다크 모드',
|
'topbar.darkMode': '다크 모드',
|
||||||
'topbar.fullscreen': '전체 화면',
|
'topbar.fullscreen': '전체 화면',
|
||||||
|
|
@ -54,6 +55,11 @@ const ko: TranslationKeys = {
|
||||||
'topbar.connected': '연결됨',
|
'topbar.connected': '연결됨',
|
||||||
'topbar.agentStatus': '에이전트 {{agents}}개{{agentSuffix}} · MCP {{mcp}}개',
|
'topbar.agentStatus': '에이전트 {{agents}}개{{agentSuffix}} · MCP {{mcp}}개',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': '디자인',
|
||||||
|
'rightPanel.code': '코드',
|
||||||
|
'rightPanel.noSelection': '요소를 선택하세요',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': '페이지',
|
'pages.title': '페이지',
|
||||||
'pages.addPage': '페이지 추가',
|
'pages.addPage': '페이지 추가',
|
||||||
|
|
@ -105,6 +111,9 @@ const ko: TranslationKeys = {
|
||||||
'layerMenu.createComponent': '컴포넌트 만들기',
|
'layerMenu.createComponent': '컴포넌트 만들기',
|
||||||
'layerMenu.detachComponent': '컴포넌트 분리',
|
'layerMenu.detachComponent': '컴포넌트 분리',
|
||||||
'layerMenu.detachInstance': '인스턴스 분리',
|
'layerMenu.detachInstance': '인스턴스 분리',
|
||||||
|
'layerMenu.booleanUnion': '합치기',
|
||||||
|
'layerMenu.booleanSubtract': '빼기',
|
||||||
|
'layerMenu.booleanIntersect': '교차',
|
||||||
'layerMenu.toggleLock': '잠금 전환',
|
'layerMenu.toggleLock': '잠금 전환',
|
||||||
'layerMenu.toggleVisibility': '표시 전환',
|
'layerMenu.toggleVisibility': '표시 전환',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const ko: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': '클립보드에 복사',
|
'code.copyClipboard': '클립보드에 복사',
|
||||||
|
'code.copied': '복사됨!',
|
||||||
|
'code.download': '코드 파일 다운로드',
|
||||||
'code.closeCodePanel': '코드 패널 닫기',
|
'code.closeCodePanel': '코드 패널 닫기',
|
||||||
'code.genCssVars': '전체 문서의 CSS 변수를 생성 중',
|
'code.genCssVars': '전체 문서의 CSS 변수를 생성 중',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'선택한 요소 {{count}}개의 코드를 생성 중',
|
'선택한 요소 {{count}}개의 코드를 생성 중',
|
||||||
'code.genDocument': '전체 문서의 코드를 생성 중',
|
'code.genDocument': '전체 문서의 코드를 생성 중',
|
||||||
|
'code.aiEnhance': 'AI 개선',
|
||||||
|
'code.cancelEnhance': '개선 취소',
|
||||||
|
'code.resetEnhance': '원본으로 복원',
|
||||||
|
'code.enhancing': 'AI가 코드를 개선 중...',
|
||||||
|
'code.enhanced': 'AI로 개선됨',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': '다른 이름으로 저장',
|
'save.saveAs': '다른 이름으로 저장',
|
||||||
|
|
@ -303,6 +319,7 @@ const ko: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': '실행 중',
|
'agents.mcpServerRunning': '실행 중',
|
||||||
'agents.mcpServerStopped': '정지됨',
|
'agents.mcpServerStopped': '정지됨',
|
||||||
'agents.mcpLanAccess': 'LAN 접근',
|
'agents.mcpLanAccess': 'LAN 접근',
|
||||||
|
'agents.mcpClientConfig': '클라이언트 설정',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const pt: TranslationKeys = {
|
||||||
'topbar.open': 'Abrir',
|
'topbar.open': 'Abrir',
|
||||||
'topbar.save': 'Salvar',
|
'topbar.save': 'Salvar',
|
||||||
'topbar.importFigma': 'Importar Figma',
|
'topbar.importFigma': 'Importar Figma',
|
||||||
|
'topbar.codePanel': 'Código',
|
||||||
'topbar.lightMode': 'Modo claro',
|
'topbar.lightMode': 'Modo claro',
|
||||||
'topbar.darkMode': 'Modo escuro',
|
'topbar.darkMode': 'Modo escuro',
|
||||||
'topbar.fullscreen': 'Tela cheia',
|
'topbar.fullscreen': 'Tela cheia',
|
||||||
|
|
@ -54,6 +55,11 @@ const pt: TranslationKeys = {
|
||||||
'topbar.connected': 'conectado',
|
'topbar.connected': 'conectado',
|
||||||
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} agente{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Design',
|
||||||
|
'rightPanel.code': 'Código',
|
||||||
|
'rightPanel.noSelection': 'Selecione um elemento',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Páginas',
|
'pages.title': 'Páginas',
|
||||||
'pages.addPage': 'Adicionar página',
|
'pages.addPage': 'Adicionar página',
|
||||||
|
|
@ -107,6 +113,9 @@ const pt: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Criar componente',
|
'layerMenu.createComponent': 'Criar componente',
|
||||||
'layerMenu.detachComponent': 'Desanexar componente',
|
'layerMenu.detachComponent': 'Desanexar componente',
|
||||||
'layerMenu.detachInstance': 'Desanexar instância',
|
'layerMenu.detachInstance': 'Desanexar instância',
|
||||||
|
'layerMenu.booleanUnion': 'União',
|
||||||
|
'layerMenu.booleanSubtract': 'Subtrair',
|
||||||
|
'layerMenu.booleanIntersect': 'Interseção',
|
||||||
'layerMenu.toggleLock': 'Alternar bloqueio',
|
'layerMenu.toggleLock': 'Alternar bloqueio',
|
||||||
'layerMenu.toggleVisibility': 'Alternar visibilidade',
|
'layerMenu.toggleVisibility': 'Alternar visibilidade',
|
||||||
|
|
||||||
|
|
@ -266,11 +275,18 @@ const pt: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Copiar para a área de transferência',
|
'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.closeCodePanel': 'Fechar painel de código',
|
||||||
'code.genCssVars': 'Gerando variáveis CSS para o documento inteiro',
|
'code.genCssVars': 'Gerando variáveis CSS para o documento inteiro',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Gerando código para {{count}} elemento(s) selecionado(s)',
|
'Gerando código para {{count}} elemento(s) selecionado(s)',
|
||||||
'code.genDocument': 'Gerando código para o documento inteiro',
|
'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 Dialog ──
|
||||||
'save.saveAs': 'Salvar como',
|
'save.saveAs': 'Salvar como',
|
||||||
|
|
@ -305,6 +321,7 @@ const pt: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Em execução',
|
'agents.mcpServerRunning': 'Em execução',
|
||||||
'agents.mcpServerStopped': 'Parado',
|
'agents.mcpServerStopped': 'Parado',
|
||||||
'agents.mcpLanAccess': 'Acesso LAN',
|
'agents.mcpLanAccess': 'Acesso LAN',
|
||||||
|
'agents.mcpClientConfig': 'Config. do cliente',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const ru: TranslationKeys = {
|
||||||
'topbar.open': 'Открыть',
|
'topbar.open': 'Открыть',
|
||||||
'topbar.save': 'Сохранить',
|
'topbar.save': 'Сохранить',
|
||||||
'topbar.importFigma': 'Импорт из Figma',
|
'topbar.importFigma': 'Импорт из Figma',
|
||||||
|
'topbar.codePanel': 'Код',
|
||||||
'topbar.lightMode': 'Светлая тема',
|
'topbar.lightMode': 'Светлая тема',
|
||||||
'topbar.darkMode': 'Тёмная тема',
|
'topbar.darkMode': 'Тёмная тема',
|
||||||
'topbar.fullscreen': 'Полный экран',
|
'topbar.fullscreen': 'Полный экран',
|
||||||
|
|
@ -54,6 +55,11 @@ const ru: TranslationKeys = {
|
||||||
'topbar.connected': 'подключено',
|
'topbar.connected': 'подключено',
|
||||||
'topbar.agentStatus': '{{agents}} агент{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} агент{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Дизайн',
|
||||||
|
'rightPanel.code': 'Код',
|
||||||
|
'rightPanel.noSelection': 'Выберите элемент',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Страницы',
|
'pages.title': 'Страницы',
|
||||||
'pages.addPage': 'Добавить страницу',
|
'pages.addPage': 'Добавить страницу',
|
||||||
|
|
@ -107,6 +113,9 @@ const ru: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Создать компонент',
|
'layerMenu.createComponent': 'Создать компонент',
|
||||||
'layerMenu.detachComponent': 'Отсоединить компонент',
|
'layerMenu.detachComponent': 'Отсоединить компонент',
|
||||||
'layerMenu.detachInstance': 'Отсоединить экземпляр',
|
'layerMenu.detachInstance': 'Отсоединить экземпляр',
|
||||||
|
'layerMenu.booleanUnion': 'Объединение',
|
||||||
|
'layerMenu.booleanSubtract': 'Вычитание',
|
||||||
|
'layerMenu.booleanIntersect': 'Пересечение',
|
||||||
'layerMenu.toggleLock': 'Переключить блокировку',
|
'layerMenu.toggleLock': 'Переключить блокировку',
|
||||||
'layerMenu.toggleVisibility': 'Переключить видимость',
|
'layerMenu.toggleVisibility': 'Переключить видимость',
|
||||||
|
|
||||||
|
|
@ -266,11 +275,18 @@ const ru: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Копировать в буфер обмена',
|
'code.copyClipboard': 'Копировать в буфер обмена',
|
||||||
|
'code.copied': 'Скопировано!',
|
||||||
|
'code.download': 'Скачать файл с кодом',
|
||||||
'code.closeCodePanel': 'Закрыть панель кода',
|
'code.closeCodePanel': 'Закрыть панель кода',
|
||||||
'code.genCssVars': 'Генерация CSS-переменных для всего документа',
|
'code.genCssVars': 'Генерация CSS-переменных для всего документа',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Генерация кода для {{count}} выделенных элементов',
|
'Генерация кода для {{count}} выделенных элементов',
|
||||||
'code.genDocument': 'Генерация кода для всего документа',
|
'code.genDocument': 'Генерация кода для всего документа',
|
||||||
|
'code.aiEnhance': 'Улучшить с ИИ',
|
||||||
|
'code.cancelEnhance': 'Отменить улучшение',
|
||||||
|
'code.resetEnhance': 'Сбросить',
|
||||||
|
'code.enhancing': 'ИИ улучшает код...',
|
||||||
|
'code.enhanced': 'Улучшено ИИ',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': 'Сохранить как',
|
'save.saveAs': 'Сохранить как',
|
||||||
|
|
@ -305,6 +321,7 @@ const ru: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Работает',
|
'agents.mcpServerRunning': 'Работает',
|
||||||
'agents.mcpServerStopped': 'Остановлен',
|
'agents.mcpServerStopped': 'Остановлен',
|
||||||
'agents.mcpLanAccess': 'Доступ по LAN',
|
'agents.mcpLanAccess': 'Доступ по LAN',
|
||||||
|
'agents.mcpClientConfig': 'Конфиг клиента',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const th: TranslationKeys = {
|
||||||
'topbar.open': 'เปิด',
|
'topbar.open': 'เปิด',
|
||||||
'topbar.save': 'บันทึก',
|
'topbar.save': 'บันทึก',
|
||||||
'topbar.importFigma': 'นำเข้า Figma',
|
'topbar.importFigma': 'นำเข้า Figma',
|
||||||
|
'topbar.codePanel': 'โค้ด',
|
||||||
'topbar.lightMode': 'โหมดสว่าง',
|
'topbar.lightMode': 'โหมดสว่าง',
|
||||||
'topbar.darkMode': 'โหมดมืด',
|
'topbar.darkMode': 'โหมดมืด',
|
||||||
'topbar.fullscreen': 'เต็มหน้าจอ',
|
'topbar.fullscreen': 'เต็มหน้าจอ',
|
||||||
|
|
@ -54,6 +55,11 @@ const th: TranslationKeys = {
|
||||||
'topbar.connected': 'เชื่อมต่อแล้ว',
|
'topbar.connected': 'เชื่อมต่อแล้ว',
|
||||||
'topbar.agentStatus': '{{agents}} เอเจนต์{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} เอเจนต์{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'ออกแบบ',
|
||||||
|
'rightPanel.code': 'โค้ด',
|
||||||
|
'rightPanel.noSelection': 'เลือกองค์ประกอบ',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'หน้า',
|
'pages.title': 'หน้า',
|
||||||
'pages.addPage': 'เพิ่มหน้า',
|
'pages.addPage': 'เพิ่มหน้า',
|
||||||
|
|
@ -105,6 +111,9 @@ const th: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'สร้างคอมโพเนนต์',
|
'layerMenu.createComponent': 'สร้างคอมโพเนนต์',
|
||||||
'layerMenu.detachComponent': 'แยกคอมโพเนนต์',
|
'layerMenu.detachComponent': 'แยกคอมโพเนนต์',
|
||||||
'layerMenu.detachInstance': 'แยกอินสแตนซ์',
|
'layerMenu.detachInstance': 'แยกอินสแตนซ์',
|
||||||
|
'layerMenu.booleanUnion': 'รวม',
|
||||||
|
'layerMenu.booleanSubtract': 'ลบ',
|
||||||
|
'layerMenu.booleanIntersect': 'ตัดกัน',
|
||||||
'layerMenu.toggleLock': 'สลับล็อก',
|
'layerMenu.toggleLock': 'สลับล็อก',
|
||||||
'layerMenu.toggleVisibility': 'สลับการมองเห็น',
|
'layerMenu.toggleVisibility': 'สลับการมองเห็น',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const th: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'คัดลอกไปยังคลิปบอร์ด',
|
'code.copyClipboard': 'คัดลอกไปยังคลิปบอร์ด',
|
||||||
|
'code.copied': 'คัดลอกแล้ว!',
|
||||||
|
'code.download': 'ดาวน์โหลดไฟล์โค้ด',
|
||||||
'code.closeCodePanel': 'ปิดแผงโค้ด',
|
'code.closeCodePanel': 'ปิดแผงโค้ด',
|
||||||
'code.genCssVars': 'กำลังสร้าง CSS Variables สำหรับเอกสารทั้งหมด',
|
'code.genCssVars': 'กำลังสร้าง CSS Variables สำหรับเอกสารทั้งหมด',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'กำลังสร้างโค้ดสำหรับ {{count}} องค์ประกอบที่เลือก',
|
'กำลังสร้างโค้ดสำหรับ {{count}} องค์ประกอบที่เลือก',
|
||||||
'code.genDocument': 'กำลังสร้างโค้ดสำหรับเอกสารทั้งหมด',
|
'code.genDocument': 'กำลังสร้างโค้ดสำหรับเอกสารทั้งหมด',
|
||||||
|
'code.aiEnhance': 'ปรับปรุงด้วย AI',
|
||||||
|
'code.cancelEnhance': 'ยกเลิกการปรับปรุง',
|
||||||
|
'code.resetEnhance': 'กลับเป็นต้นฉบับ',
|
||||||
|
'code.enhancing': 'AI กำลังปรับปรุงโค้ด...',
|
||||||
|
'code.enhanced': 'ปรับปรุงแล้วโดย AI',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': 'บันทึกเป็น',
|
'save.saveAs': 'บันทึกเป็น',
|
||||||
|
|
@ -303,6 +319,7 @@ const th: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'กำลังทำงาน',
|
'agents.mcpServerRunning': 'กำลังทำงาน',
|
||||||
'agents.mcpServerStopped': 'หยุดแล้ว',
|
'agents.mcpServerStopped': 'หยุดแล้ว',
|
||||||
'agents.mcpLanAccess': 'เข้าถึง LAN',
|
'agents.mcpLanAccess': 'เข้าถึง LAN',
|
||||||
|
'agents.mcpClientConfig': 'การตั้งค่าไคลเอนต์',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const tr: TranslationKeys = {
|
||||||
'topbar.open': 'Aç',
|
'topbar.open': 'Aç',
|
||||||
'topbar.save': 'Kaydet',
|
'topbar.save': 'Kaydet',
|
||||||
'topbar.importFigma': 'Figma İçe Aktar',
|
'topbar.importFigma': 'Figma İçe Aktar',
|
||||||
|
'topbar.codePanel': 'Kod',
|
||||||
'topbar.lightMode': 'Açık mod',
|
'topbar.lightMode': 'Açık mod',
|
||||||
'topbar.darkMode': 'Koyu mod',
|
'topbar.darkMode': 'Koyu mod',
|
||||||
'topbar.fullscreen': 'Tam ekran',
|
'topbar.fullscreen': 'Tam ekran',
|
||||||
|
|
@ -54,6 +55,11 @@ const tr: TranslationKeys = {
|
||||||
'topbar.connected': 'bağlı',
|
'topbar.connected': 'bağlı',
|
||||||
'topbar.agentStatus': '{{agents}} ajan{{agentSuffix}} · {{mcp}} MCP',
|
'topbar.agentStatus': '{{agents}} ajan{{agentSuffix}} · {{mcp}} MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': 'Tasarım',
|
||||||
|
'rightPanel.code': 'Kod',
|
||||||
|
'rightPanel.noSelection': 'Bir öğe seçin',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': 'Sayfalar',
|
'pages.title': 'Sayfalar',
|
||||||
'pages.addPage': 'Sayfa ekle',
|
'pages.addPage': 'Sayfa ekle',
|
||||||
|
|
@ -105,6 +111,9 @@ const tr: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Bileşen Oluştur',
|
'layerMenu.createComponent': 'Bileşen Oluştur',
|
||||||
'layerMenu.detachComponent': 'Bileşeni Ayır',
|
'layerMenu.detachComponent': 'Bileşeni Ayır',
|
||||||
'layerMenu.detachInstance': 'Örneği 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.toggleLock': 'Kilidi Aç/Kapat',
|
||||||
'layerMenu.toggleVisibility': 'Görünürlüğü Aç/Kapat',
|
'layerMenu.toggleVisibility': 'Görünürlüğü Aç/Kapat',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const tr: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Panoya kopyala',
|
'code.copyClipboard': 'Panoya kopyala',
|
||||||
|
'code.copied': 'Kopyalandı!',
|
||||||
|
'code.download': 'Kod dosyasını indir',
|
||||||
'code.closeCodePanel': 'Kod panelini kapat',
|
'code.closeCodePanel': 'Kod panelini kapat',
|
||||||
'code.genCssVars': 'Tüm belge için CSS değişkenleri oluşturuluyor',
|
'code.genCssVars': 'Tüm belge için CSS değişkenleri oluşturuluyor',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'{{count}} seçili öge için kod oluşturuluyor',
|
'{{count}} seçili öge için kod oluşturuluyor',
|
||||||
'code.genDocument': 'Tüm belge 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 Dialog ──
|
||||||
'save.saveAs': 'Farklı Kaydet',
|
'save.saveAs': 'Farklı Kaydet',
|
||||||
|
|
@ -303,6 +319,7 @@ const tr: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Çalışıyor',
|
'agents.mcpServerRunning': 'Çalışıyor',
|
||||||
'agents.mcpServerStopped': 'Durduruldu',
|
'agents.mcpServerStopped': 'Durduruldu',
|
||||||
'agents.mcpLanAccess': 'LAN Erişimi',
|
'agents.mcpLanAccess': 'LAN Erişimi',
|
||||||
|
'agents.mcpClientConfig': 'İstemci yapılandırması',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const vi: TranslationKeys = {
|
||||||
'topbar.open': 'Mở',
|
'topbar.open': 'Mở',
|
||||||
'topbar.save': 'Lưu',
|
'topbar.save': 'Lưu',
|
||||||
'topbar.importFigma': 'Nhập từ Figma',
|
'topbar.importFigma': 'Nhập từ Figma',
|
||||||
|
'topbar.codePanel': 'Mã',
|
||||||
'topbar.lightMode': 'Chế độ sáng',
|
'topbar.lightMode': 'Chế độ sáng',
|
||||||
'topbar.darkMode': 'Chế độ tối',
|
'topbar.darkMode': 'Chế độ tối',
|
||||||
'topbar.fullscreen': 'Toàn màn hình',
|
'topbar.fullscreen': 'Toàn màn hình',
|
||||||
|
|
@ -54,6 +55,11 @@ const vi: TranslationKeys = {
|
||||||
'topbar.connected': 'đã kết nối',
|
'topbar.connected': 'đã kết nối',
|
||||||
'topbar.agentStatus': '{{agents}} agent{{agentSuffix}} · {{mcp}} MCP',
|
'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 ──
|
||||||
'pages.title': 'Trang',
|
'pages.title': 'Trang',
|
||||||
'pages.addPage': 'Thêm trang',
|
'pages.addPage': 'Thêm trang',
|
||||||
|
|
@ -105,6 +111,9 @@ const vi: TranslationKeys = {
|
||||||
'layerMenu.createComponent': 'Tạo thành phần',
|
'layerMenu.createComponent': 'Tạo thành phần',
|
||||||
'layerMenu.detachComponent': 'Tách thành phần',
|
'layerMenu.detachComponent': 'Tách thành phần',
|
||||||
'layerMenu.detachInstance': 'Tách bản thể',
|
'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.toggleLock': 'Bật/Tắt khoá',
|
||||||
'layerMenu.toggleVisibility': 'Bật/Tắt hiển thị',
|
'layerMenu.toggleVisibility': 'Bật/Tắt hiển thị',
|
||||||
|
|
||||||
|
|
@ -264,11 +273,18 @@ const vi: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': 'Sao chép vào bộ nhớ tạm',
|
'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.closeCodePanel': 'Đóng bảng mã',
|
||||||
'code.genCssVars': 'Đang tạo CSS variables cho toàn bộ tài liệu',
|
'code.genCssVars': 'Đang tạo CSS variables cho toàn bộ tài liệu',
|
||||||
'code.genSelected':
|
'code.genSelected':
|
||||||
'Đang tạo mã cho {{count}} phần tử đã chọn',
|
'Đ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.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 Dialog ──
|
||||||
'save.saveAs': 'Lưu thành',
|
'save.saveAs': 'Lưu thành',
|
||||||
|
|
@ -303,6 +319,7 @@ const vi: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': 'Đang chạy',
|
'agents.mcpServerRunning': 'Đang chạy',
|
||||||
'agents.mcpServerStopped': 'Đã dừng',
|
'agents.mcpServerStopped': 'Đã dừng',
|
||||||
'agents.mcpLanAccess': 'Truy cập LAN',
|
'agents.mcpLanAccess': 'Truy cập LAN',
|
||||||
|
'agents.mcpClientConfig': 'Cấu hình client',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const zhTW: TranslationKeys = {
|
||||||
'topbar.open': '開啟',
|
'topbar.open': '開啟',
|
||||||
'topbar.save': '儲存',
|
'topbar.save': '儲存',
|
||||||
'topbar.importFigma': '匯入 Figma',
|
'topbar.importFigma': '匯入 Figma',
|
||||||
|
'topbar.codePanel': '程式碼',
|
||||||
'topbar.lightMode': '淺色模式',
|
'topbar.lightMode': '淺色模式',
|
||||||
'topbar.darkMode': '深色模式',
|
'topbar.darkMode': '深色模式',
|
||||||
'topbar.fullscreen': '全螢幕',
|
'topbar.fullscreen': '全螢幕',
|
||||||
|
|
@ -54,6 +55,11 @@ const zhTW: TranslationKeys = {
|
||||||
'topbar.connected': '已連線',
|
'topbar.connected': '已連線',
|
||||||
'topbar.agentStatus': '{{agents}} 個 Agent{{agentSuffix}} · {{mcp}} 個 MCP',
|
'topbar.agentStatus': '{{agents}} 個 Agent{{agentSuffix}} · {{mcp}} 個 MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': '設計',
|
||||||
|
'rightPanel.code': '程式碼',
|
||||||
|
'rightPanel.noSelection': '選擇一個元素',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': '頁面',
|
'pages.title': '頁面',
|
||||||
'pages.addPage': '新增頁面',
|
'pages.addPage': '新增頁面',
|
||||||
|
|
@ -102,6 +108,9 @@ const zhTW: TranslationKeys = {
|
||||||
'layerMenu.createComponent': '建立元件',
|
'layerMenu.createComponent': '建立元件',
|
||||||
'layerMenu.detachComponent': '分離元件',
|
'layerMenu.detachComponent': '分離元件',
|
||||||
'layerMenu.detachInstance': '分離實例',
|
'layerMenu.detachInstance': '分離實例',
|
||||||
|
'layerMenu.booleanUnion': '聯合',
|
||||||
|
'layerMenu.booleanSubtract': '減去',
|
||||||
|
'layerMenu.booleanIntersect': '交集',
|
||||||
'layerMenu.toggleLock': '切換鎖定',
|
'layerMenu.toggleLock': '切換鎖定',
|
||||||
'layerMenu.toggleVisibility': '切換可見性',
|
'layerMenu.toggleVisibility': '切換可見性',
|
||||||
|
|
||||||
|
|
@ -258,10 +267,17 @@ const zhTW: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': '複製到剪貼簿',
|
'code.copyClipboard': '複製到剪貼簿',
|
||||||
|
'code.copied': '已複製!',
|
||||||
|
'code.download': '下載程式碼檔案',
|
||||||
'code.closeCodePanel': '關閉程式碼面板',
|
'code.closeCodePanel': '關閉程式碼面板',
|
||||||
'code.genCssVars': '正在為整份文件產生 CSS 變數',
|
'code.genCssVars': '正在為整份文件產生 CSS 變數',
|
||||||
'code.genSelected': '正在為 {{count}} 個選取元素產生程式碼',
|
'code.genSelected': '正在為 {{count}} 個選取元素產生程式碼',
|
||||||
'code.genDocument': '正在為整份文件產生程式碼',
|
'code.genDocument': '正在為整份文件產生程式碼',
|
||||||
|
'code.aiEnhance': 'AI 優化',
|
||||||
|
'code.cancelEnhance': '取消優化',
|
||||||
|
'code.resetEnhance': '恢復原始程式碼',
|
||||||
|
'code.enhancing': 'AI 正在優化程式碼...',
|
||||||
|
'code.enhanced': '已由 AI 優化',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': '另存新檔',
|
'save.saveAs': '另存新檔',
|
||||||
|
|
@ -295,6 +311,7 @@ const zhTW: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': '執行中',
|
'agents.mcpServerRunning': '執行中',
|
||||||
'agents.mcpServerStopped': '已停止',
|
'agents.mcpServerStopped': '已停止',
|
||||||
'agents.mcpLanAccess': '區域網路存取',
|
'agents.mcpLanAccess': '區域網路存取',
|
||||||
|
'agents.mcpClientConfig': '客戶端配置',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const zh: TranslationKeys = {
|
||||||
'topbar.open': '打开',
|
'topbar.open': '打开',
|
||||||
'topbar.save': '保存',
|
'topbar.save': '保存',
|
||||||
'topbar.importFigma': '导入 Figma',
|
'topbar.importFigma': '导入 Figma',
|
||||||
|
'topbar.codePanel': '代码',
|
||||||
'topbar.lightMode': '浅色模式',
|
'topbar.lightMode': '浅色模式',
|
||||||
'topbar.darkMode': '深色模式',
|
'topbar.darkMode': '深色模式',
|
||||||
'topbar.fullscreen': '全屏',
|
'topbar.fullscreen': '全屏',
|
||||||
|
|
@ -54,6 +55,11 @@ const zh: TranslationKeys = {
|
||||||
'topbar.connected': '已连接',
|
'topbar.connected': '已连接',
|
||||||
'topbar.agentStatus': '{{agents}} 个 Agent{{agentSuffix}} · {{mcp}} 个 MCP',
|
'topbar.agentStatus': '{{agents}} 个 Agent{{agentSuffix}} · {{mcp}} 个 MCP',
|
||||||
|
|
||||||
|
// ── Right Panel ──
|
||||||
|
'rightPanel.design': '设计',
|
||||||
|
'rightPanel.code': '代码',
|
||||||
|
'rightPanel.noSelection': '选择一个元素',
|
||||||
|
|
||||||
// ── Pages ──
|
// ── Pages ──
|
||||||
'pages.title': '页面',
|
'pages.title': '页面',
|
||||||
'pages.addPage': '添加页面',
|
'pages.addPage': '添加页面',
|
||||||
|
|
@ -102,6 +108,9 @@ const zh: TranslationKeys = {
|
||||||
'layerMenu.createComponent': '创建组件',
|
'layerMenu.createComponent': '创建组件',
|
||||||
'layerMenu.detachComponent': '分离组件',
|
'layerMenu.detachComponent': '分离组件',
|
||||||
'layerMenu.detachInstance': '分离实例',
|
'layerMenu.detachInstance': '分离实例',
|
||||||
|
'layerMenu.booleanUnion': '联合',
|
||||||
|
'layerMenu.booleanSubtract': '减去',
|
||||||
|
'layerMenu.booleanIntersect': '交集',
|
||||||
'layerMenu.toggleLock': '切换锁定',
|
'layerMenu.toggleLock': '切换锁定',
|
||||||
'layerMenu.toggleVisibility': '切换可见性',
|
'layerMenu.toggleVisibility': '切换可见性',
|
||||||
|
|
||||||
|
|
@ -258,10 +267,17 @@ const zh: TranslationKeys = {
|
||||||
'code.htmlCss': 'HTML + CSS',
|
'code.htmlCss': 'HTML + CSS',
|
||||||
'code.cssVariables': 'CSS Variables',
|
'code.cssVariables': 'CSS Variables',
|
||||||
'code.copyClipboard': '复制到剪贴板',
|
'code.copyClipboard': '复制到剪贴板',
|
||||||
|
'code.copied': '已复制!',
|
||||||
|
'code.download': '下载代码文件',
|
||||||
'code.closeCodePanel': '关闭代码面板',
|
'code.closeCodePanel': '关闭代码面板',
|
||||||
'code.genCssVars': '正在为整个文档生成 CSS 变量',
|
'code.genCssVars': '正在为整个文档生成 CSS 变量',
|
||||||
'code.genSelected': '正在为 {{count}} 个选中元素生成代码',
|
'code.genSelected': '正在为 {{count}} 个选中元素生成代码',
|
||||||
'code.genDocument': '正在为整个文档生成代码',
|
'code.genDocument': '正在为整个文档生成代码',
|
||||||
|
'code.aiEnhance': 'AI 优化',
|
||||||
|
'code.cancelEnhance': '取消优化',
|
||||||
|
'code.resetEnhance': '恢复原始代码',
|
||||||
|
'code.enhancing': 'AI 正在优化代码...',
|
||||||
|
'code.enhanced': '已由 AI 优化',
|
||||||
|
|
||||||
// ── Save Dialog ──
|
// ── Save Dialog ──
|
||||||
'save.saveAs': '另存为',
|
'save.saveAs': '另存为',
|
||||||
|
|
@ -295,6 +311,7 @@ const zh: TranslationKeys = {
|
||||||
'agents.mcpServerRunning': '运行中',
|
'agents.mcpServerRunning': '运行中',
|
||||||
'agents.mcpServerStopped': '已停止',
|
'agents.mcpServerStopped': '已停止',
|
||||||
'agents.mcpLanAccess': '局域网访问',
|
'agents.mcpLanAccess': '局域网访问',
|
||||||
|
'agents.mcpClientConfig': '客户端配置',
|
||||||
'agents.stdio': 'stdio',
|
'agents.stdio': 'stdio',
|
||||||
'agents.http': 'http',
|
'agents.http': 'http',
|
||||||
'agents.stdioHttp': 'stdio + http',
|
'agents.stdioHttp': 'stdio + http',
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ const cache = new Map<string, { doc: PenDocument; mtime: number }>()
|
||||||
/** Special path indicating the MCP should operate on the live Electron canvas. */
|
/** Special path indicating the MCP should operate on the live Electron canvas. */
|
||||||
export const LIVE_CANVAS_PATH = 'live://canvas'
|
export const LIVE_CANVAS_PATH = 'live://canvas'
|
||||||
|
|
||||||
/** Resolve filePath for MCP tools — passes through live://canvas, resolves file paths normally. */
|
/** Resolve filePath for MCP tools — defaults to live canvas when omitted. */
|
||||||
export function resolveDocPath(filePath: string): string {
|
export function resolveDocPath(filePath?: string): string {
|
||||||
if (filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH
|
if (!filePath || filePath === LIVE_CANVAS_PATH) return LIVE_CANVAS_PATH
|
||||||
return resolve(filePath)
|
return resolve(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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: {
|
patterns: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Search patterns to match nodes',
|
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)' },
|
searchDepth: { type: 'number', description: 'How deep to search for matching nodes (default unlimited)' },
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
||||||
},
|
},
|
||||||
required: ['filePath'],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -89,7 +89,7 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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: {
|
parent: {
|
||||||
type: ['string', 'null'] as any,
|
type: ['string', 'null'] as any,
|
||||||
description: 'Parent node ID, or null for root level',
|
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)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
nodeId: { type: 'string', description: 'ID of the node to update' },
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
@ -136,7 +136,7 @@ const TOOL_DEFINITIONS = [
|
||||||
},
|
},
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
nodeId: { type: 'string', description: 'ID of the node to delete' },
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
||||||
},
|
},
|
||||||
required: ['filePath', 'nodeId'],
|
required: ['nodeId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -159,7 +159,7 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
nodeId: { type: 'string', description: 'ID of the node to move' },
|
||||||
parent: {
|
parent: {
|
||||||
type: ['string', 'null'] as any,
|
type: ['string', 'null'] as any,
|
||||||
|
|
@ -171,7 +171,7 @@ const TOOL_DEFINITIONS = [
|
||||||
},
|
},
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
sourceId: { type: 'string', description: 'ID of the node to copy' },
|
||||||
parent: {
|
parent: {
|
||||||
type: ['string', 'null'] as any,
|
type: ['string', 'null'] as any,
|
||||||
|
|
@ -193,7 +193,7 @@ const TOOL_DEFINITIONS = [
|
||||||
},
|
},
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
nodeId: { type: 'string', description: 'ID of the node to replace' },
|
||||||
data: {
|
data: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
|
@ -219,7 +219,7 @@ const TOOL_DEFINITIONS = [
|
||||||
},
|
},
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
svgPath: { type: 'string', description: 'Absolute path to a local .svg file' },
|
||||||
parent: {
|
parent: {
|
||||||
type: ['string', 'null'] as any,
|
type: ['string', 'null'] as any,
|
||||||
|
|
@ -249,7 +249,7 @@ const TOOL_DEFINITIONS = [
|
||||||
},
|
},
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
||||||
},
|
},
|
||||||
required: ['filePath', 'svgPath'],
|
required: ['svgPath'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -258,9 +258,9 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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 })' },
|
variables: { type: 'object', description: 'Variables to set (name → { type, value })' },
|
||||||
replace: { type: 'boolean', description: 'Replace all variables instead of merging (default false)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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: {
|
themes: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
description:
|
description:
|
||||||
|
|
@ -294,7 +294,7 @@ const TOOL_DEFINITIONS = [
|
||||||
description: 'Replace all themes instead of merging (default false)',
|
description: 'Replace all themes instead of merging (default false)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['filePath', 'themes'],
|
required: ['themes'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -303,12 +303,12 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
parentId: { type: 'string', description: 'Only return layout under this parent node' },
|
||||||
maxDepth: { type: 'number', description: 'Max depth to traverse (default 1)' },
|
maxDepth: { type: 'number', description: 'Max depth to traverse (default 1)' },
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
||||||
},
|
},
|
||||||
required: ['filePath'],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -317,7 +317,7 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
width: { type: 'number', description: 'Required width of empty space' },
|
||||||
height: { type: 'number', description: 'Required height of empty space' },
|
height: { type: 'number', description: 'Required height of empty space' },
|
||||||
padding: { type: 'number', description: 'Minimum padding from other elements (default 50)' },
|
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)' },
|
nodeId: { type: 'string', description: 'Search relative to this node (default: entire canvas)' },
|
||||||
pageId: { type: 'string', description: 'Target page ID (defaults to first page)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
presetPath: { type: 'string', description: 'Absolute path for the output .optheme file' },
|
||||||
name: { type: 'string', description: 'Display name for the preset (defaults to file name)' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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")' },
|
name: { type: 'string', description: 'Page name (default: "Page N")' },
|
||||||
children: {
|
children: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|
@ -380,7 +380,7 @@ const TOOL_DEFINITIONS = [
|
||||||
items: { type: 'object' },
|
items: { type: 'object' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ['filePath'],
|
required: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -389,10 +389,10 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
pageId: { type: 'string', description: 'ID of the page to remove' },
|
||||||
},
|
},
|
||||||
required: ['filePath', 'pageId'],
|
required: ['pageId'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -401,11 +401,11 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
pageId: { type: 'string', description: 'ID of the page to rename' },
|
||||||
name: { type: 'string', description: 'New page name' },
|
name: { type: 'string', description: 'New page name' },
|
||||||
},
|
},
|
||||||
required: ['filePath', 'pageId', 'name'],
|
required: ['pageId', 'name'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -414,11 +414,11 @@ const TOOL_DEFINITIONS = [
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
pageId: { type: 'string', description: 'ID of the page to move' },
|
||||||
index: { type: 'number', description: 'New zero-based index for the page' },
|
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: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
pageId: { type: 'string', description: 'ID of the page to duplicate' },
|
||||||
name: { type: 'string', description: 'Name for the duplicated page (default: "original copy")' },
|
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 ---
|
// --- HTTP server helper ---
|
||||||
|
|
||||||
function startHttpServer(server: Server, port: number): void {
|
function startHttpServer(port: number): void {
|
||||||
const transport = new StreamableHTTPServerTransport({
|
// Per-session transport map: each client gets its own Server + Transport
|
||||||
sessionIdGenerator: () => randomUUID(),
|
const sessions = new Map<string, { transport: StreamableHTTPServerTransport; server: Server }>()
|
||||||
})
|
|
||||||
|
|
||||||
server.connect(transport)
|
|
||||||
|
|
||||||
const httpServer = createServer(async (req, res) => {
|
const httpServer = createServer(async (req, res) => {
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
|
@ -531,19 +528,62 @@ function startHttpServer(server: Server, port: number): void {
|
||||||
return
|
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') {
|
if (req.method === 'POST') {
|
||||||
const chunks: Buffer[] = []
|
const chunks: Buffer[] = []
|
||||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||||
const body = JSON.parse(Buffer.concat(chunks).toString())
|
const body = JSON.parse(Buffer.concat(chunks).toString())
|
||||||
await transport.handleRequest(req, res, body)
|
await session.transport.handleRequest(req, res, body)
|
||||||
} else {
|
} else {
|
||||||
await transport.handleRequest(req, res)
|
await session.transport.handleRequest(req, res)
|
||||||
}
|
}
|
||||||
} else {
|
return
|
||||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
||||||
res.end(JSON.stringify({ error: 'Not found. Use /mcp endpoint.' }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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', () => {
|
httpServer.listen(port, '0.0.0.0', () => {
|
||||||
|
|
@ -569,7 +609,7 @@ async function main() {
|
||||||
const { stdio, http, port } = parseArgs()
|
const { stdio, http, port } = parseArgs()
|
||||||
|
|
||||||
if (stdio && http) {
|
if (stdio && http) {
|
||||||
// Both: two Server instances sharing the same tool handlers
|
// Both: stdio server + HTTP server (per-session)
|
||||||
const stdioServer = new Server(
|
const stdioServer = new Server(
|
||||||
{ name: pkg.name, version: pkg.version },
|
{ name: pkg.name, version: pkg.version },
|
||||||
{ capabilities: { tools: {} } },
|
{ capabilities: { tools: {} } },
|
||||||
|
|
@ -577,19 +617,9 @@ async function main() {
|
||||||
registerTools(stdioServer)
|
registerTools(stdioServer)
|
||||||
await stdioServer.connect(new StdioServerTransport())
|
await stdioServer.connect(new StdioServerTransport())
|
||||||
|
|
||||||
const httpServer = new Server(
|
startHttpServer(port)
|
||||||
{ name: pkg.name, version: pkg.version },
|
|
||||||
{ capabilities: { tools: {} } },
|
|
||||||
)
|
|
||||||
registerTools(httpServer)
|
|
||||||
startHttpServer(httpServer, port)
|
|
||||||
} else if (http) {
|
} else if (http) {
|
||||||
const server = new Server(
|
startHttpServer(port)
|
||||||
{ name: pkg.name, version: pkg.version },
|
|
||||||
{ capabilities: { tools: {} } },
|
|
||||||
)
|
|
||||||
registerTools(server)
|
|
||||||
startHttpServer(server, port)
|
|
||||||
} else {
|
} else {
|
||||||
const server = new Server(
|
const server = new Server(
|
||||||
{ name: pkg.name, version: pkg.version },
|
{ name: pkg.name, version: pkg.version },
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
import type { PenDocument, PenNode } from '../../types/pen'
|
import type { PenDocument, PenNode } from '../../types/pen'
|
||||||
|
|
||||||
export interface BatchDesignParams {
|
export interface BatchDesignParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
operations: string
|
operations: string
|
||||||
postProcess?: boolean
|
postProcess?: boolean
|
||||||
canvasWidth?: number
|
canvasWidth?: number
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export interface SearchPattern {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchGetParams {
|
export interface BatchGetParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
patterns?: SearchPattern[]
|
patterns?: SearchPattern[]
|
||||||
nodeIds?: string[]
|
nodeIds?: string[]
|
||||||
parentId?: string
|
parentId?: string
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,55 @@ ${ADAPTIVE_STYLE_POLICY}
|
||||||
|
|
||||||
${DESIGN_EXAMPLES}
|
${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):
|
LAYOUT ENGINE (flexbox-based):
|
||||||
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
|
- 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
|
- 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
|
- 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.
|
- 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.
|
- 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.
|
- Section root: width="fill_container", height="fit_content", layout="vertical". Never fixed pixel height on section root.
|
||||||
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes. OMIT height — the engine auto-calculates.
|
- Two-column: horizontal frame, two child frames each "fill_container" width.
|
||||||
- CJK BUTTONS/BADGES: each CJK char ≈ fontSize wide. Ensure container width ≥ (charCount × fontSize) + padding.
|
- 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:
|
COPYWRITING:
|
||||||
- Headlines: 2-6 words. Subtitles: 1 sentence ≤15 words. Buttons: 1-3 words. Card text: ≤2 sentences.
|
- 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.
|
- NEVER generate placeholder paragraphs with 3+ sentences. Distill to essence.
|
||||||
|
|
||||||
DESIGN GUIDELINES:
|
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.
|
- 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.
|
- 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".
|
- 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.
|
- Icon-only buttons: square ≥44×44, justifyContent/alignItems="center", path icon 20-24px.
|
||||||
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use height="fill_container".
|
- Inputs: height 48px, light bg, subtle border, width="fill_container" in forms.
|
||||||
- Icons: "path" nodes with Feather icon names (PascalCase + "Icon" suffix). Size 16-24px. System auto-resolves names to SVG paths.
|
Semantic affordance icons: search→leading SearchIcon, password→trailing EyeIcon, email→leading MailIcon.
|
||||||
- Never use emoji as icons. Never use ellipse for decorative shapes.
|
- Cards: cornerRadius 12-16, clipContent: true, subtle shadows. Cards in a horizontal row: ALL use width="fill_container" + height="fill_container".
|
||||||
- Phone mockup: ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke.
|
- 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.
|
- 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:
|
DESIGN VARIABLES:
|
||||||
- When document has variables, use "$variableName" references instead of hardcoded values.
|
- When document has variables, use "$variableName" references instead of hardcoded values.
|
||||||
- Color variables: [{ "type": "solid", "color": "$primary" }]
|
- Color variables: [{ "type": "solid", "color": "$primary" }]
|
||||||
- Number variables: "gap": "$spacing-md"
|
- 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:
|
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.
|
- 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.
|
- 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.
|
- Always use I(null, {...}) for root-level designs — the tool handles reuse of empty frames automatically.
|
||||||
|
|
||||||
POST-PROCESSING (automatic):
|
POST-PROCESSING (automatic with postProcess=true):
|
||||||
- batch_design with postProcess=true automatically applies after insertion:
|
- Semantic role defaults: fills unset props based on role (see SEMANTIC ROLES above). Context-aware — e.g. button defaults differ in navbar vs form.
|
||||||
- Semantic role defaults (button padding, card corners, input styling, etc.)
|
- Icon name → SVG path auto-resolution: set icon "name" field, system resolves to SVG "d" path.
|
||||||
- Icon name → SVG path resolution
|
- Card row equalization: horizontal layout with 2+ cards auto-equalizes to fill_container width+height.
|
||||||
- Emoji removal
|
- Horizontal overflow fix: auto-reduces gap or expands parent when children exceed width.
|
||||||
- Layout child position sanitization
|
- Form input consistency: if any input uses fill_container, all sibling inputs get normalized.
|
||||||
- Unique ID enforcement
|
- 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.`
|
Always set postProcess=true when generating designs for best visual quality.`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getNodeBounds, findNodeInTree, getDocChildren } from '../utils/node-ope
|
||||||
import type { PenNode } from '../../types/pen'
|
import type { PenNode } from '../../types/pen'
|
||||||
|
|
||||||
export interface FindEmptySpaceParams {
|
export interface FindEmptySpaceParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
padding?: number
|
padding?: number
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { parseSvgToNodesServer } from '../utils/svg-node-parser'
|
||||||
import { postProcessNode } from './node-crud'
|
import { postProcessNode } from './node-crud'
|
||||||
|
|
||||||
export interface ImportSvgParams {
|
export interface ImportSvgParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
svgPath: string
|
svgPath: string
|
||||||
parent?: string | null
|
parent?: string | null
|
||||||
maxDim?: number
|
maxDim?: number
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ function isEmptyFrame(node: PenNode): boolean {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface InsertNodeParams {
|
export interface InsertNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
parent: string | null
|
parent: string | null
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
postProcess?: boolean
|
postProcess?: boolean
|
||||||
|
|
@ -132,7 +132,7 @@ export async function handleInsertNode(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface UpdateNodeParams {
|
export interface UpdateNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
nodeId: string
|
nodeId: string
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
postProcess?: boolean
|
postProcess?: boolean
|
||||||
|
|
@ -164,7 +164,7 @@ export async function handleUpdateNode(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface DeleteNodeParams {
|
export interface DeleteNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
nodeId: string
|
nodeId: string
|
||||||
pageId?: string
|
pageId?: string
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ export async function handleDeleteNode(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface MoveNodeParams {
|
export interface MoveNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
nodeId: string
|
nodeId: string
|
||||||
parent: string | null
|
parent: string | null
|
||||||
index?: number
|
index?: number
|
||||||
|
|
@ -221,7 +221,7 @@ export async function handleMoveNode(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface CopyNodeParams {
|
export interface CopyNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
sourceId: string
|
sourceId: string
|
||||||
parent: string | null
|
parent: string | null
|
||||||
overrides?: Record<string, any>
|
overrides?: Record<string, any>
|
||||||
|
|
@ -260,7 +260,7 @@ export async function handleCopyNode(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface ReplaceNodeParams {
|
export interface ReplaceNodeParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
nodeId: string
|
nodeId: string
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
postProcess?: boolean
|
postProcess?: boolean
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { PenPage, PenNode } from '../../types/pen'
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface AddPageParams {
|
export interface AddPageParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
name?: string
|
name?: string
|
||||||
children?: Record<string, any>[]
|
children?: Record<string, any>[]
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ export async function handleAddPage(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface RemovePageParams {
|
export interface RemovePageParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
pageId: string
|
pageId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,7 +91,7 @@ export async function handleRemovePage(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface RenamePageParams {
|
export interface RenamePageParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
pageId: string
|
pageId: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
@ -118,7 +118,7 @@ export async function handleRenamePage(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface ReorderPageParams {
|
export interface ReorderPageParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
pageId: string
|
pageId: string
|
||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +147,7 @@ export async function handleReorderPage(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface DuplicatePageParams {
|
export interface DuplicatePageParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
pageId: string
|
pageId: string
|
||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { openDocument, resolveDocPath } from '../document-manager'
|
||||||
import { computeLayoutTree, getDocChildren, type LayoutEntry } from '../utils/node-operations'
|
import { computeLayoutTree, getDocChildren, type LayoutEntry } from '../utils/node-operations'
|
||||||
|
|
||||||
export interface SnapshotLayoutParams {
|
export interface SnapshotLayoutParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
parentId?: string
|
parentId?: string
|
||||||
maxDepth?: number
|
maxDepth?: number
|
||||||
pageId?: string
|
pageId?: string
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { VariableDefinition } from '../../types/variables'
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface SaveThemePresetParams {
|
export interface SaveThemePresetParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
presetPath: string
|
presetPath: string
|
||||||
name?: string
|
name?: string
|
||||||
}
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ export async function handleSaveThemePreset(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface LoadThemePresetParams {
|
export interface LoadThemePresetParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
presetPath: string
|
presetPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ import { openDocument, saveDocument, resolveDocPath } from '../document-manager'
|
||||||
import type { VariableDefinition } from '../../types/variables'
|
import type { VariableDefinition } from '../../types/variables'
|
||||||
|
|
||||||
export interface GetVariablesParams {
|
export interface GetVariablesParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetVariablesParams {
|
export interface SetVariablesParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
variables: Record<string, VariableDefinition>
|
variables: Record<string, VariableDefinition>
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +43,7 @@ export async function handleSetVariables(
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface SetThemesParams {
|
export interface SetThemesParams {
|
||||||
filePath: string
|
filePath?: string
|
||||||
themes: Record<string, string[]>
|
themes: Record<string, string[]>
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,9 @@ export const DESIGN_STREAM_TIMEOUTS = {
|
||||||
effort: DEFAULT_THINKING_EFFORT,
|
effort: DEFAULT_THINKING_EFFORT,
|
||||||
} as const
|
} 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 = {
|
export const RETRY_TIMEOUT_CONFIG = {
|
||||||
multiplier: 2,
|
multiplier: 2,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export interface AIDesignRequest {
|
||||||
model?: string
|
model?: string
|
||||||
provider?: AIProviderType
|
provider?: AIProviderType
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
|
/** Generation mode: 'direct' uses current pipeline, 'visual-ref' uses visual reference pipeline */
|
||||||
|
mode?: 'direct' | 'visual-ref'
|
||||||
context?: {
|
context?: {
|
||||||
selectedNodes?: string[]
|
selectedNodes?: string[]
|
||||||
documentSummary?: 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 {
|
export interface AICodeRequest {
|
||||||
prompt?: string
|
prompt?: string
|
||||||
format: 'react-tailwind' | 'html-css' | 'react-inline'
|
format: 'react-tailwind' | 'html-css' | 'react-inline'
|
||||||
|
|
@ -62,6 +107,8 @@ export interface SubTask {
|
||||||
parentFrameId: string | null
|
parentFrameId: string | null
|
||||||
/** Screen/page grouping — subtasks with the same screen share one root frame */
|
/** Screen/page grouping — subtasks with the same screen share one root frame */
|
||||||
screen?: string
|
screen?: string
|
||||||
|
/** HTML reference snippet for this section (from visual reference pipeline) */
|
||||||
|
htmlReference?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Style guide produced by the orchestrator for visual consistency */
|
/** Style guide produced by the orchestrator for visual consistency */
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
createPhonePlaceholderDataUri,
|
createPhonePlaceholderDataUri,
|
||||||
estimateNodeIntrinsicHeight,
|
estimateNodeIntrinsicHeight,
|
||||||
} from './generation-utils'
|
} from './generation-utils'
|
||||||
|
import { defaultLineHeight } from '@/canvas/canvas-text-measure'
|
||||||
import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons, resolveAllPendingIcons } from './icon-resolver'
|
import { applyIconPathResolution, applyNoEmojiIconHeuristic, resolveAsyncIcons, resolveAllPendingIcons } from './icon-resolver'
|
||||||
import {
|
import {
|
||||||
resolveNodeRole,
|
resolveNodeRole,
|
||||||
|
|
@ -44,10 +45,15 @@ let generationCanvasWidth = 1200
|
||||||
/** Root frame ID for the current generation — may differ from DEFAULT_FRAME_ID
|
/** 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. */
|
* when canvas already has content and new content is placed beside it. */
|
||||||
let generationRootFrameId: string = DEFAULT_FRAME_ID
|
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<string>()
|
||||||
|
|
||||||
export function resetGenerationRemapping(): void {
|
export function resetGenerationRemapping(): void {
|
||||||
generationRemappedIds.clear()
|
generationRemappedIds.clear()
|
||||||
generationRootFrameId = DEFAULT_FRAME_ID
|
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 {
|
export function setGenerationContextHint(hint?: string): void {
|
||||||
|
|
@ -158,8 +164,7 @@ export function insertStreamingNode(
|
||||||
}
|
}
|
||||||
// Default lineHeight based on text role (heading vs body)
|
// Default lineHeight based on text role (heading vs body)
|
||||||
if (!node.lineHeight) {
|
if (!node.lineHeight) {
|
||||||
const fs = node.fontSize ?? 16
|
node.lineHeight = defaultLineHeight(node.fontSize ?? 16)
|
||||||
node.lineHeight = fs >= 28 ? 1.2 : 1.5
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +179,11 @@ export function insertStreamingNode(
|
||||||
|
|
||||||
applyGenerationHeuristics(node)
|
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
|
// Skip AI-streamed children under phone placeholders. Placeholder internals are
|
||||||
// normalized post-streaming (at most one centered label text is allowed).
|
// 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).
|
// 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)
|
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<string, number>()
|
const counters = new Map<string, number>()
|
||||||
const used = new Set<string>()
|
const used = new Set(preExistingNodeIds)
|
||||||
|
const newRemaps = new Map<string, string>()
|
||||||
for (const node of cloned) {
|
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
|
return cloned
|
||||||
|
|
|
||||||
170
src/services/ai/design-code-generator.ts
Normal file
170
src/services/ai/design-code-generator.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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('<!DOCTYPE') || content.includes('<html')) {
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the response itself starts with HTML
|
||||||
|
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find HTML document in the response
|
||||||
|
const htmlMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i)
|
||||||
|
if (htmlMatch) {
|
||||||
|
return htmlMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: wrap bare content
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Design</title></head>
|
||||||
|
<body>${trimmed}</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = /<h([1-6])[^>]*>([\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
|
||||||
|
}
|
||||||
67
src/services/ai/design-code-prompts.ts
Normal file
67
src/services/ai/design-code-prompts.ts
Normal file
|
|
@ -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 <!DOCTYPE html>. No explanation.
|
||||||
|
- ALL CSS must be inline in a <style> block. No external stylesheets except Google Fonts.
|
||||||
|
- Use modern CSS: flexbox, gap, custom properties, clamp().
|
||||||
|
- The page must render correctly at the specified viewport dimensions.
|
||||||
|
- All images use colored placeholder rectangles with labels (no external images).
|
||||||
|
- Icons use simple inline SVG shapes (geometric, not complex).
|
||||||
|
- Include Google Fonts via <link> in the <head> if non-system fonts are specified.
|
||||||
|
|
||||||
|
DESIGN QUALITY:
|
||||||
|
- This is a visual reference for a design tool — every pixel matters.
|
||||||
|
- Create clear visual hierarchy: one dominant element per section, everything else subordinate.
|
||||||
|
- Use whitespace generously — premium designs breathe.
|
||||||
|
- Avoid template-ish layouts: don't put everything in the center, explore asymmetry.
|
||||||
|
- Color should guide the eye: accent color on CTAs and key elements, neutral everywhere else.
|
||||||
|
- Typography should create rhythm: vary size, weight, and color across the type scale.
|
||||||
|
- Shadows should be subtle (0 2px 8px rgba(0,0,0,0.08)) — never heavy drop shadows.
|
||||||
|
- Corner radius should be consistent across the design (8-12px for modern, 16px+ for friendly).
|
||||||
|
- Sections should flow naturally: alternate background tints, use generous vertical padding (80-120px).
|
||||||
|
|
||||||
|
ANTI-PATTERNS TO AVOID:
|
||||||
|
- Every card looking identical with blue icon + black title + gray text (the "AI template" look).
|
||||||
|
- Centered everything — real designs use left-alignment and asymmetric layouts.
|
||||||
|
- Too many things competing for attention — ruthlessly prioritize.
|
||||||
|
- Decorative elements that serve no purpose — every element must earn its place.
|
||||||
|
- Generic stock-photo-style image placeholders — use branded colored rectangles.
|
||||||
|
- All buttons the same size and color — create a button hierarchy.
|
||||||
|
|
||||||
|
TEXT CONTENT:
|
||||||
|
- Headlines: 2-6 words, punchy and specific to the product.
|
||||||
|
- Subtitles: 1 sentence, max 15 words.
|
||||||
|
- Feature descriptions: 1 sentence, max 20 words.
|
||||||
|
- Button text: 1-3 words.
|
||||||
|
- Never use lorem ipsum or generic "Your text here" placeholders.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt for HTML/CSS code generation.
|
||||||
|
* Includes the design system tokens and viewport constraints.
|
||||||
|
*/
|
||||||
|
export function buildCodeGenUserPrompt(
|
||||||
|
userPrompt: string,
|
||||||
|
designSystemContext: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): string {
|
||||||
|
const heightInstruction = height > 0
|
||||||
|
? `Height: ${height}px (fixed viewport).`
|
||||||
|
: `Height: auto (content determines height, estimate based on sections).`
|
||||||
|
|
||||||
|
return `Design request: ${userPrompt}
|
||||||
|
|
||||||
|
Viewport: Width ${width}px. ${heightInstruction}
|
||||||
|
|
||||||
|
${designSystemContext}
|
||||||
|
|
||||||
|
Generate the complete HTML file now.`
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import type { AIDesignRequest } from './ai-types'
|
||||||
import { streamChat } from './ai-service'
|
import { streamChat } from './ai-service'
|
||||||
import { DESIGN_MODIFIER_PROMPT } from './ai-prompts'
|
import { DESIGN_MODIFIER_PROMPT } from './ai-prompts'
|
||||||
import { executeOrchestration } from './orchestrator'
|
import { executeOrchestration } from './orchestrator'
|
||||||
|
import { executeVisualRefOrchestration } from './visual-ref-orchestrator'
|
||||||
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
|
import { DESIGN_STREAM_TIMEOUTS } from './ai-runtime-config'
|
||||||
import { extractJsonFromResponse } from './design-parser'
|
import { extractJsonFromResponse } from './design-parser'
|
||||||
|
|
||||||
|
|
@ -80,6 +81,9 @@ export async function generateDesign(
|
||||||
},
|
},
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
||||||
|
if (request.mode === 'visual-ref') {
|
||||||
|
return executeVisualRefOrchestration(request, callbacks, abortSignal)
|
||||||
|
}
|
||||||
return executeOrchestration(request, callbacks, abortSignal)
|
return executeOrchestration(request, callbacks, abortSignal)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,9 @@ export function ensureUniqueNodeIds(
|
||||||
node: PenNode,
|
node: PenNode,
|
||||||
used: Set<string>,
|
used: Set<string>,
|
||||||
counters: Map<string, number>,
|
counters: Map<string, number>,
|
||||||
|
remapping?: Map<string, string>,
|
||||||
): void {
|
): void {
|
||||||
|
const originalId = node.id
|
||||||
const base = normalizeIdBase(node.id, node.type)
|
const base = normalizeIdBase(node.id, node.type)
|
||||||
let finalId = base
|
let finalId = base
|
||||||
|
|
||||||
|
|
@ -165,11 +167,16 @@ export function ensureUniqueNodeIds(
|
||||||
node.id = finalId
|
node.id = finalId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track original→new mapping so progressive upsert can resolve IDs
|
||||||
|
if (remapping && originalId && finalId !== originalId) {
|
||||||
|
remapping.set(originalId, finalId)
|
||||||
|
}
|
||||||
|
|
||||||
used.add(finalId)
|
used.add(finalId)
|
||||||
|
|
||||||
if (!('children' in node) || !Array.isArray(node.children)) return
|
if (!('children' in node) || !Array.isArray(node.children)) return
|
||||||
for (const child of node.children) {
|
for (const child of node.children) {
|
||||||
ensureUniqueNodeIds(child, used, counters)
|
ensureUniqueNodeIds(child, used, counters, remapping)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
251
src/services/ai/design-pre-validation.ts
Normal file
251
src/services/ai/design-pre-validation.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
/**
|
||||||
|
* Pre-validation: pure code checks that don't require LLM.
|
||||||
|
*
|
||||||
|
* Inspired by Pencil's search_all_unique_properties + replace_all_matching_properties.
|
||||||
|
* Runs before vision API validation to fix obvious inconsistencies:
|
||||||
|
* 1. Invisible containers — frame with same fill as parent → add border
|
||||||
|
* 2. Empty icons — path nodes without geometry data
|
||||||
|
* 3. Sibling consistency — majority-rule for height, cornerRadius, fontSize
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
|
||||||
|
import type { PenNode } from '@/types/pen'
|
||||||
|
|
||||||
|
interface PreValidationFix {
|
||||||
|
nodeId: string
|
||||||
|
property: string
|
||||||
|
value: unknown
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pre-validation checks on the generated design.
|
||||||
|
* Returns the number of fixes applied.
|
||||||
|
*/
|
||||||
|
export function runPreValidationFixes(): number {
|
||||||
|
const store = useDocumentStore.getState()
|
||||||
|
const root = store.getNodeById(DEFAULT_FRAME_ID)
|
||||||
|
if (!root) return 0
|
||||||
|
|
||||||
|
const fixes: PreValidationFix[] = []
|
||||||
|
|
||||||
|
// Pass 1: Find invisible containers (same fill as parent → needs border)
|
||||||
|
detectInvisibleContainers(root, null, fixes, store)
|
||||||
|
|
||||||
|
// Pass 2: Find empty path/icon nodes (missing geometry)
|
||||||
|
detectEmptyPaths(root, fixes)
|
||||||
|
|
||||||
|
// Pass 3: Find sibling property inconsistencies
|
||||||
|
detectSiblingInconsistencies(root, fixes)
|
||||||
|
|
||||||
|
// Deduplicate (a node might get multiple fixes for same property)
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const uniqueFixes = fixes.filter((f) => {
|
||||||
|
const key = `${f.nodeId}:${f.property}`
|
||||||
|
if (seen.has(key)) return false
|
||||||
|
seen.add(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
for (const fix of uniqueFixes) {
|
||||||
|
if (fix.property === '__remove') {
|
||||||
|
store.removeNode(fix.nodeId)
|
||||||
|
console.log(`[Pre-validation] ${fix.nodeId}: removed (${fix.reason})`)
|
||||||
|
} else {
|
||||||
|
store.updateNode(fix.nodeId, { [fix.property]: fix.value })
|
||||||
|
console.log(
|
||||||
|
`[Pre-validation] ${fix.nodeId}: ${fix.property} → ${JSON.stringify(fix.value)} (${fix.reason})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueFixes.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Extract the first fill color from a node (raw, including variable refs) */
|
||||||
|
function getFirstFillColor(node: PenNode): string | null {
|
||||||
|
if (!('fill' in node) || !Array.isArray(node.fill) || node.fill.length === 0) return null
|
||||||
|
const first = node.fill[0]
|
||||||
|
if (first && 'color' in first && first.color) return first.color
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a node already has a visible stroke */
|
||||||
|
function hasStroke(node: PenNode): boolean {
|
||||||
|
if (!('stroke' in node)) return false
|
||||||
|
const s = node.stroke as { thickness?: number } | undefined
|
||||||
|
return s != null && (s.thickness ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve border color: prefer $color-border variable if it exists, else neutral gray */
|
||||||
|
function getBorderStroke(store: ReturnType<typeof useDocumentStore.getState>): {
|
||||||
|
thickness: number
|
||||||
|
fill: Array<{ type: 'solid'; color: string }>
|
||||||
|
} {
|
||||||
|
const doc = store.document
|
||||||
|
const hasBorderVar = doc.variables && 'color-border' in doc.variables
|
||||||
|
const color = hasBorderVar ? '$color-border' : '#E2E8F0'
|
||||||
|
return { thickness: 1, fill: [{ type: 'solid', color }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pass 1: Invisible container detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect containers with same fill as parent.
|
||||||
|
* These blend visually and need a border to be distinguishable.
|
||||||
|
*/
|
||||||
|
function detectInvisibleContainers(
|
||||||
|
node: PenNode,
|
||||||
|
parentFillColor: string | null,
|
||||||
|
fixes: PreValidationFix[],
|
||||||
|
store: ReturnType<typeof useDocumentStore.getState>,
|
||||||
|
): void {
|
||||||
|
const nodeFill = getFirstFillColor(node)
|
||||||
|
|
||||||
|
if (
|
||||||
|
parentFillColor &&
|
||||||
|
nodeFill &&
|
||||||
|
nodeFill === parentFillColor &&
|
||||||
|
!hasStroke(node) &&
|
||||||
|
node.type === 'frame' &&
|
||||||
|
'layout' in node &&
|
||||||
|
node.layout && // only layout frames (inputs, buttons, cards)
|
||||||
|
'children' in node &&
|
||||||
|
node.children &&
|
||||||
|
node.children.length > 0 // has content inside
|
||||||
|
) {
|
||||||
|
fixes.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
property: 'stroke',
|
||||||
|
value: getBorderStroke(store),
|
||||||
|
reason: `same fill as parent (${nodeFill})`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse
|
||||||
|
if ('children' in node && node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
detectInvisibleContainers(child, nodeFill ?? parentFillColor, fixes, store)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pass 2: Empty path/icon detection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect path nodes without geometry data.
|
||||||
|
* These render as invisible empty rectangles on canvas.
|
||||||
|
*/
|
||||||
|
function detectEmptyPaths(node: PenNode, fixes: PreValidationFix[]): void {
|
||||||
|
if (node.type === 'path') {
|
||||||
|
const hasD = 'd' in node && (node as unknown as Record<string, unknown>).d
|
||||||
|
if (!hasD) {
|
||||||
|
fixes.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
property: '__remove',
|
||||||
|
value: true,
|
||||||
|
reason: 'path node without geometry (renders invisible)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('children' in node && node.children) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
detectEmptyPaths(child, fixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pass 3: Sibling property consistency
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Properties to check for consistency among same-type siblings */
|
||||||
|
const FRAME_CONSISTENCY_PROPS = ['height', 'cornerRadius'] as const
|
||||||
|
const TEXT_CONSISTENCY_PROPS = ['fontSize'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect property inconsistencies among siblings.
|
||||||
|
* Uses majority rule: if >= 2/3 of siblings agree on a value, fix the outlier.
|
||||||
|
*/
|
||||||
|
function detectSiblingInconsistencies(node: PenNode, fixes: PreValidationFix[]): void {
|
||||||
|
if (!('children' in node) || !node.children) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need at least 3 siblings for meaningful majority
|
||||||
|
if (node.children.length >= 3) {
|
||||||
|
// Group children by type
|
||||||
|
const groups = new Map<string, PenNode[]>()
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (!groups.has(child.type)) groups.set(child.type, [])
|
||||||
|
groups.get(child.type)!.push(child)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, siblings] of groups) {
|
||||||
|
if (siblings.length < 3) continue
|
||||||
|
const props = type === 'text' ? TEXT_CONSISTENCY_PROPS : FRAME_CONSISTENCY_PROPS
|
||||||
|
for (const prop of props) {
|
||||||
|
checkConsistency(siblings, prop, fixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse
|
||||||
|
for (const child of node.children) {
|
||||||
|
detectSiblingInconsistencies(child, fixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkConsistency(
|
||||||
|
siblings: PenNode[],
|
||||||
|
property: string,
|
||||||
|
fixes: PreValidationFix[],
|
||||||
|
): void {
|
||||||
|
const values = new Map<string, { value: unknown; nodes: PenNode[] }>()
|
||||||
|
|
||||||
|
for (const node of siblings) {
|
||||||
|
const raw = (node as unknown as Record<string, unknown>)[property]
|
||||||
|
if (raw == null) continue
|
||||||
|
const key = JSON.stringify(raw)
|
||||||
|
if (!values.has(key)) values.set(key, { value: raw, nodes: [] })
|
||||||
|
values.get(key)!.nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.size < 2) return // all same, no inconsistency
|
||||||
|
|
||||||
|
// Find majority
|
||||||
|
let majority: { value: unknown; nodes: PenNode[] } | null = null
|
||||||
|
for (const entry of values.values()) {
|
||||||
|
if (!majority || entry.nodes.length > majority.nodes.length) {
|
||||||
|
majority = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!majority) return
|
||||||
|
|
||||||
|
// Only fix if clear majority (>= 2/3)
|
||||||
|
const totalWithProp = Array.from(values.values()).reduce((s, e) => s + e.nodes.length, 0)
|
||||||
|
if (majority.nodes.length < (totalWithProp * 2) / 3) return
|
||||||
|
|
||||||
|
// Fix outliers
|
||||||
|
for (const entry of values.values()) {
|
||||||
|
if (entry === majority) continue
|
||||||
|
for (const node of entry.nodes) {
|
||||||
|
fixes.push({
|
||||||
|
nodeId: node.id,
|
||||||
|
property,
|
||||||
|
value: majority.value,
|
||||||
|
reason: `inconsistent with ${majority.nodes.length} siblings`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/services/ai/design-principles/color.ts
Normal file
13
src/services/ai/design-principles/color.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Color theory design principles — selective loading based on request context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COLOR_PRINCIPLES = `COLOR THEORY & PALETTE:
|
||||||
|
- A strong palette has 1 primary action color, 1 accent for secondary actions, and a neutral scale for text/backgrounds. Max 2 saturated colors — more creates visual noise.
|
||||||
|
- Create depth with the neutral scale: page bg slightly tinted (not pure #FFFFFF — use #F8FAFC, #FAFAF9, or #F5F3FF for subtle warmth/coolness), card surface pure white, text dark but not black (#0F172A reads softer than #000000).
|
||||||
|
- Contrast is law: text on backgrounds must pass WCAG AA (4.5:1 for body, 3:1 for large text). Test your accent color on both light and dark surfaces.
|
||||||
|
- Use color temperature to set mood: blue/slate = trust/tech, warm gray/amber = friendly/creative, emerald/teal = growth/health, violet/indigo = premium/creative.
|
||||||
|
- Gradient use: subtle gradients on hero backgrounds (30-degree angle, 2 related hues) add polish. Avoid rainbow gradients or harsh color jumps.
|
||||||
|
- Borders should be barely visible (#E2E8F0 on white, rgba(255,255,255,0.1) on dark) — they separate, not decorate.
|
||||||
|
- Dark themes: background #0F172A or #18181B (never pure black), surface #1E293B, text #F8FAFC. Accent colors should be slightly lighter/more saturated than in light themes.
|
||||||
|
- Avoid the "AI palette" trap: not every design needs blue primary + white cards. Match colors to the product domain — fintech can use deep navy, health apps can use sage green, creative tools can use coral/amber.`
|
||||||
14
src/services/ai/design-principles/components.ts
Normal file
14
src/services/ai/design-principles/components.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
/**
|
||||||
|
* Component design patterns — selective loading based on request context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COMPONENT_PRINCIPLES = `COMPONENT DESIGN PATTERNS:
|
||||||
|
- Buttons have weight hierarchy: primary (filled, accent color) for THE action, secondary (outlined or ghost) for alternatives, tertiary (text-only) for less important. Never two primary buttons side by side.
|
||||||
|
- Cards: consistent corner radius (12-16px), consistent padding (24-32px), consistent shadow depth. Content inside follows vertical rhythm: image → title → description → action. Separate concerns into body and footer.
|
||||||
|
- Navigation: keep it minimal — logo, 3-5 links, one CTA. The nav bar is wayfinding, not a menu explosion. Use space_between for distribution.
|
||||||
|
- Forms: all inputs same width (fill_container), consistent height (44-48px), clear labels above inputs, submit button at bottom with same width. Group related fields. Don't forget affordance icons (search, email, password).
|
||||||
|
- Hero sections: one clear headline, one supporting sentence, one or two CTAs, optional visual (image/mockup). Resist adding more — every extra element dilutes the focus.
|
||||||
|
- Feature sections: icon + title + description per card. Icons should be uniform size and style. Descriptions should be 1 sentence. Three or four cards max per row.
|
||||||
|
- Pricing cards: highlight the recommended plan with accent background or border. Keep plan names short. Use checkmark lists for features, not paragraphs.
|
||||||
|
- Testimonials: real-feeling names, short quotes (1-2 sentences), optional avatar. Three cards or a slider, not a wall of text.
|
||||||
|
- Footer: organized in 3-4 column groups (product, company, resources, social). Muted colors, smaller text. Don't cram everything from the nav here.`
|
||||||
13
src/services/ai/design-principles/composition.ts
Normal file
13
src/services/ai/design-principles/composition.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Visual composition and hierarchy principles — selective loading based on request context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COMPOSITION_PRINCIPLES = `VISUAL HIERARCHY & COMPOSITION:
|
||||||
|
- Every screen has ONE primary focal point. In a landing page hero, it's the headline. In a dashboard, it's the key metric. Make it unmistakably dominant through size, contrast, and space.
|
||||||
|
- Create visual weight through the hierarchy chain: focal headline → supporting subtitle → action CTA → secondary content. Each level should be clearly subordinate to the one above.
|
||||||
|
- Use contrast pairs to create interest: large text next to small, dark block next to light, dense section next to spacious. Monotone layouts feel flat.
|
||||||
|
- Section backgrounds: alternate between white and tinted (#F8FAFC / #F1F5F9) to create natural separation. This is more elegant than borders or heavy shadows.
|
||||||
|
- The Z-pattern for landing pages: eye scans top-left (logo) → top-right (CTA) → middle-left (value prop) → bottom-right (action). Place elements along this path.
|
||||||
|
- Visual grouping: items that belong together should share a container (card, subtle background). Items that don't should have clear separation. Proximity = relationship.
|
||||||
|
- Shadows create depth layers: no shadow = background, subtle shadow = floating card, medium shadow = modal/dropdown. Don't put heavy shadows on everything.
|
||||||
|
- Balance asymmetric layouts: a two-column hero with 60% text / 40% image is more dynamic than 50/50. The text side carries visual weight through content density.`
|
||||||
35
src/services/ai/design-principles/index.ts
Normal file
35
src/services/ai/design-principles/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Design principles — domain-specific design knowledge extracted from
|
||||||
|
* professional design practices.
|
||||||
|
*
|
||||||
|
* All principles are always included since the total content is small (~60 lines).
|
||||||
|
* Selective loading via keyword matching was removed to avoid hardcoded keyword lists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TYPOGRAPHY_PRINCIPLES } from './typography'
|
||||||
|
import { COLOR_PRINCIPLES } from './color'
|
||||||
|
import { SPACING_PRINCIPLES } from './spacing'
|
||||||
|
import { COMPOSITION_PRINCIPLES } from './composition'
|
||||||
|
import { COMPONENT_PRINCIPLES } from './components'
|
||||||
|
|
||||||
|
const ALL_PRINCIPLES = [
|
||||||
|
COMPOSITION_PRINCIPLES,
|
||||||
|
TYPOGRAPHY_PRINCIPLES,
|
||||||
|
COLOR_PRINCIPLES,
|
||||||
|
SPACING_PRINCIPLES,
|
||||||
|
COMPONENT_PRINCIPLES,
|
||||||
|
].join('\n\n')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all design principles combined.
|
||||||
|
*/
|
||||||
|
export function getAllPrinciples(): string {
|
||||||
|
return ALL_PRINCIPLES
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export individual principles for direct access
|
||||||
|
export { TYPOGRAPHY_PRINCIPLES } from './typography'
|
||||||
|
export { COLOR_PRINCIPLES } from './color'
|
||||||
|
export { SPACING_PRINCIPLES } from './spacing'
|
||||||
|
export { COMPOSITION_PRINCIPLES } from './composition'
|
||||||
|
export { COMPONENT_PRINCIPLES } from './components'
|
||||||
13
src/services/ai/design-principles/spacing.ts
Normal file
13
src/services/ai/design-principles/spacing.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Spacing and layout design principles — selective loading based on request context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SPACING_PRINCIPLES = `SPACING & LAYOUT RHYTHM:
|
||||||
|
- Use an 8px grid: all spacing values should be multiples of 8 (8, 16, 24, 32, 48, 64, 80, 96). This creates visual consistency that humans perceive subconsciously.
|
||||||
|
- Section padding: hero/major sections need generous breathing room (80-120px vertical, 80px horizontal). Cramped sections feel cheap.
|
||||||
|
- Gap hierarchy mirrors content hierarchy: related items 8-16px apart, groups 24-32px, sections 48-80px. The gap between a title and its paragraph should be smaller than the gap between two cards.
|
||||||
|
- Padding inside containers should scale with the container size: small cards 16-20px, medium cards 24-32px, large sections 48-80px. Uniform 16px on everything looks template-ish.
|
||||||
|
- Consistent content width: keep the main content column at 1040-1160px across sections. Sections can have full-bleed backgrounds but content must align. Inconsistent content widths look amateurish.
|
||||||
|
- White space is a design element, not wasted space. A hero section with generous padding looks premium; a hero crammed with elements looks like a flyer.
|
||||||
|
- Cards in a row: equal width via fill_container, equal height via fill_container, consistent padding, consistent gap. Uneven cards break visual rhythm.
|
||||||
|
- Responsive readability: body text line length should be 50-75 characters. In a 1200px layout, this means text columns of ~600-700px, not full-width.`
|
||||||
13
src/services/ai/design-principles/typography.ts
Normal file
13
src/services/ai/design-principles/typography.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Typography design principles — selective loading based on request context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TYPOGRAPHY_PRINCIPLES = `TYPOGRAPHY CRAFT:
|
||||||
|
- Build a clear type scale with real contrast: display 48-64px, heading 28-36px, body 16px. Jumps must be noticeable — 16 to 18 is not a "heading".
|
||||||
|
- Pair fonts with purpose: a geometric sans (Space Grotesk, Outfit) for headlines creates personality; a neutral sans (Inter, DM Sans) for body ensures readability. Never use the same font weight everywhere.
|
||||||
|
- Weight creates hierarchy: 700 for titles, 500 for subtitles, 400 for body. Using 600 for everything makes nothing stand out.
|
||||||
|
- Line height is tighter at large sizes (1.05-1.15 for 40px+) and looser at small sizes (1.5-1.6 for 16px). This is how print typography works.
|
||||||
|
- Letter spacing: pull headlines tighter (-0.5 to -1px) for density, push uppercase labels wider (+1-2px) for legibility. Body stays at 0.
|
||||||
|
- Use font weight shifts for emphasis within paragraphs (500 for inline emphasis), not font-size changes. Size hierarchy is structural, weight hierarchy is inline.
|
||||||
|
- Headlines are short and punchy (2-6 words). If your headline needs two lines, the font size is too large or the text is too verbose.
|
||||||
|
- Color reinforces hierarchy: primary text (#0F172A-level) for headings, muted (#475569-level) for body, lighter still for captions. Never same color at same opacity for all text.`
|
||||||
67
src/services/ai/design-screenshot.ts
Normal file
67
src/services/ai/design-screenshot.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Screenshot capture utilities for design validation.
|
||||||
|
* Supports both full root frame and per-node screenshots.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCanvasStore } from '@/stores/canvas-store'
|
||||||
|
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
|
||||||
|
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a screenshot of a specific node and all its descendants.
|
||||||
|
* Returns a base64 PNG data URL, or null if the node can't be rendered.
|
||||||
|
*/
|
||||||
|
export function captureNodeScreenshot(nodeId: string): string | null {
|
||||||
|
const canvas = useCanvasStore.getState().fabricCanvas
|
||||||
|
if (!canvas) return null
|
||||||
|
|
||||||
|
const store = useDocumentStore.getState()
|
||||||
|
if (!store.getNodeById(nodeId)) return null
|
||||||
|
|
||||||
|
const allFlat = store.getFlatNodes()
|
||||||
|
const descendantIds = new Set<string>()
|
||||||
|
for (const node of allFlat) {
|
||||||
|
if (node.id !== nodeId && store.isDescendantOf(node.id, nodeId)) {
|
||||||
|
descendantIds.add(node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||||
|
const rootObj = allObjects.find((obj) => obj.penNodeId === nodeId)
|
||||||
|
if (!rootObj) return null
|
||||||
|
|
||||||
|
const originX = rootObj.left ?? 0
|
||||||
|
const originY = rootObj.top ?? 0
|
||||||
|
const w = (rootObj.width ?? 0) * (rootObj.scaleX ?? 1)
|
||||||
|
const h = (rootObj.height ?? 0) * (rootObj.scaleY ?? 1)
|
||||||
|
|
||||||
|
if (w <= 0 || h <= 0) return null
|
||||||
|
|
||||||
|
const allIds = new Set(descendantIds)
|
||||||
|
allIds.add(nodeId)
|
||||||
|
|
||||||
|
const layerObjects = allObjects.filter(
|
||||||
|
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
|
||||||
|
)
|
||||||
|
|
||||||
|
const offscreen = document.createElement('canvas')
|
||||||
|
offscreen.width = Math.ceil(w)
|
||||||
|
offscreen.height = Math.ceil(h)
|
||||||
|
const ctx = offscreen.getContext('2d')
|
||||||
|
if (!ctx) return null
|
||||||
|
|
||||||
|
ctx.translate(-originX, -originY)
|
||||||
|
|
||||||
|
for (const obj of layerObjects) {
|
||||||
|
obj.render(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return offscreen.toDataURL('image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture a screenshot of the entire root frame.
|
||||||
|
*/
|
||||||
|
export function captureRootFrameScreenshot(): string | null {
|
||||||
|
return captureNodeScreenshot(DEFAULT_FRAME_ID)
|
||||||
|
}
|
||||||
175
src/services/ai/design-system-generator.ts
Normal file
175
src/services/ai/design-system-generator.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
/**
|
||||||
|
* Design System Generator (Stage 0 of visual reference pipeline).
|
||||||
|
*
|
||||||
|
* Generates structured design tokens (colors, typography, spacing) from
|
||||||
|
* a user's design request. The tokens serve dual purpose:
|
||||||
|
* 1. Guide HTML code generation for consistent design
|
||||||
|
* 2. Map to PenDocument.variables for design system integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DesignSystem } from './ai-types'
|
||||||
|
import type { AIProviderType } from '@/types/agent-settings'
|
||||||
|
import type { VariableDefinition } from '@/types/variables'
|
||||||
|
import { generateCompletion } from './ai-service'
|
||||||
|
import { DESIGN_SYSTEM_PROMPT } from './design-system-prompts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a design system from a user's prompt.
|
||||||
|
* Uses a fast model (Haiku-class) for speed since output is small JSON.
|
||||||
|
*/
|
||||||
|
export async function generateDesignSystem(
|
||||||
|
prompt: string,
|
||||||
|
model?: string,
|
||||||
|
provider?: AIProviderType,
|
||||||
|
): Promise<DesignSystem> {
|
||||||
|
const response = await generateCompletion(
|
||||||
|
DESIGN_SYSTEM_PROMPT,
|
||||||
|
prompt,
|
||||||
|
model,
|
||||||
|
provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
return parseDesignSystem(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a design system from AI response text.
|
||||||
|
* Tolerant of code fences and surrounding text.
|
||||||
|
*/
|
||||||
|
function parseDesignSystem(text: string): DesignSystem {
|
||||||
|
const trimmed = text.trim()
|
||||||
|
|
||||||
|
// Try direct parse
|
||||||
|
const direct = tryParseDS(trimmed)
|
||||||
|
if (direct) return direct
|
||||||
|
|
||||||
|
// Try extracting from code fences
|
||||||
|
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
||||||
|
if (fenceMatch) {
|
||||||
|
const fenced = tryParseDS(fenceMatch[1].trim())
|
||||||
|
if (fenced) return fenced
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try extracting first { ... } block
|
||||||
|
const firstBrace = trimmed.indexOf('{')
|
||||||
|
const lastBrace = trimmed.lastIndexOf('}')
|
||||||
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||||
|
const braced = tryParseDS(trimmed.slice(firstBrace, lastBrace + 1))
|
||||||
|
if (braced) return braced
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return default design system
|
||||||
|
return DEFAULT_DESIGN_SYSTEM
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParseDS(json: string): DesignSystem | null {
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(json) as Record<string, unknown>
|
||||||
|
if (!obj.palette || typeof obj.palette !== 'object') return null
|
||||||
|
if (!obj.typography || typeof obj.typography !== 'object') return null
|
||||||
|
|
||||||
|
const p = obj.palette as Record<string, string>
|
||||||
|
const t = obj.typography as Record<string, unknown>
|
||||||
|
const s = (obj.spacing as Record<string, unknown>) ?? { unit: 8, scale: [8, 16, 24, 32, 48, 64] }
|
||||||
|
|
||||||
|
return {
|
||||||
|
palette: {
|
||||||
|
background: p.background ?? '#F8FAFC',
|
||||||
|
surface: p.surface ?? '#FFFFFF',
|
||||||
|
text: p.text ?? '#0F172A',
|
||||||
|
textSecondary: p.textSecondary ?? '#475569',
|
||||||
|
primary: p.primary ?? '#2563EB',
|
||||||
|
primaryLight: p.primaryLight ?? '#DBEAFE',
|
||||||
|
accent: p.accent ?? '#0EA5E9',
|
||||||
|
border: p.border ?? '#E2E8F0',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
headingFont: (t.headingFont as string) ?? 'Space Grotesk',
|
||||||
|
bodyFont: (t.bodyFont as string) ?? 'Inter',
|
||||||
|
scale: Array.isArray(t.scale) ? (t.scale as number[]) : [14, 16, 20, 28, 40, 56],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
unit: (s.unit as number) ?? 8,
|
||||||
|
scale: Array.isArray(s.scale) ? (s.scale as number[]) : [8, 16, 24, 32, 48, 64],
|
||||||
|
},
|
||||||
|
radius: Array.isArray(obj.radius) ? (obj.radius as number[]) : [8, 12, 16],
|
||||||
|
aesthetic: (obj.aesthetic as string) ?? 'clean modern',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_DESIGN_SYSTEM: DesignSystem = {
|
||||||
|
palette: {
|
||||||
|
background: '#F8FAFC',
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
text: '#0F172A',
|
||||||
|
textSecondary: '#475569',
|
||||||
|
primary: '#2563EB',
|
||||||
|
primaryLight: '#DBEAFE',
|
||||||
|
accent: '#0EA5E9',
|
||||||
|
border: '#E2E8F0',
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
headingFont: 'Space Grotesk',
|
||||||
|
bodyFont: 'Inter',
|
||||||
|
scale: [14, 16, 20, 28, 40, 56],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
unit: 8,
|
||||||
|
scale: [8, 16, 24, 32, 48, 64],
|
||||||
|
},
|
||||||
|
radius: [8, 12, 16],
|
||||||
|
aesthetic: 'clean modern blue',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Map design system → PenDocument.variables
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a DesignSystem into PenDocument variable definitions.
|
||||||
|
* These are stored in the document and referenced as $variable-name in nodes.
|
||||||
|
*/
|
||||||
|
export function designSystemToVariables(ds: DesignSystem): Record<string, VariableDefinition> {
|
||||||
|
const vars: Record<string, VariableDefinition> = {}
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
for (const [key, value] of Object.entries(ds.palette)) {
|
||||||
|
const name = `color-${kebab(key)}`
|
||||||
|
vars[name] = { type: 'color', value }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spacing
|
||||||
|
const spacingNames = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']
|
||||||
|
for (let i = 0; i < ds.spacing.scale.length && i < spacingNames.length; i++) {
|
||||||
|
vars[`spacing-${spacingNames[i]}`] = { type: 'number', value: ds.spacing.scale[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radius
|
||||||
|
const radiusNames = ['sm', 'md', 'lg', 'xl']
|
||||||
|
for (let i = 0; i < ds.radius.length && i < radiusNames.length; i++) {
|
||||||
|
vars[`radius-${radiusNames[i]}`] = { type: 'number', value: ds.radius[i] }
|
||||||
|
}
|
||||||
|
|
||||||
|
return vars
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a concise design system context string for AI prompts.
|
||||||
|
*/
|
||||||
|
export function designSystemToPromptContext(ds: DesignSystem): string {
|
||||||
|
const p = ds.palette
|
||||||
|
return `DESIGN SYSTEM (use these values consistently):
|
||||||
|
Colors: bg ${p.background}, surface ${p.surface}, text ${p.text}, muted ${p.textSecondary}, primary ${p.primary}, primaryLight ${p.primaryLight}, accent ${p.accent}, border ${p.border}
|
||||||
|
Fonts: heading "${ds.typography.headingFont}", body "${ds.typography.bodyFont}"
|
||||||
|
Type scale: ${ds.typography.scale.join(', ')}px
|
||||||
|
Spacing: ${ds.spacing.scale.join(', ')}px (${ds.spacing.unit}px grid)
|
||||||
|
Radius: ${ds.radius.join(', ')}px
|
||||||
|
Style: ${ds.aesthetic}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function kebab(str: string): string {
|
||||||
|
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
|
}
|
||||||
42
src/services/ai/design-system-prompts.ts
Normal file
42
src/services/ai/design-system-prompts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* Prompts for the design system generator (Stage 0 of visual reference pipeline).
|
||||||
|
* Produces structured design tokens from a user's design request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DESIGN_SYSTEM_PROMPT = `You are a design system architect. Given a product description, create a cohesive design token system.
|
||||||
|
Output ONLY a JSON object, no explanation.
|
||||||
|
|
||||||
|
{
|
||||||
|
"palette": {
|
||||||
|
"background": "#hex (page bg, slightly tinted — never pure white)",
|
||||||
|
"surface": "#hex (card/container bg)",
|
||||||
|
"text": "#hex (primary text, dark but not black)",
|
||||||
|
"textSecondary": "#hex (body/secondary text, muted)",
|
||||||
|
"primary": "#hex (main action color)",
|
||||||
|
"primaryLight": "#hex (lighter tint for hover/subtle backgrounds)",
|
||||||
|
"accent": "#hex (secondary accent, complementary to primary)",
|
||||||
|
"border": "#hex (subtle dividers)"
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"headingFont": "font name (display/personality font)",
|
||||||
|
"bodyFont": "font name (readable/neutral font)",
|
||||||
|
"scale": [14, 16, 20, 28, 40, 56]
|
||||||
|
},
|
||||||
|
"spacing": {
|
||||||
|
"unit": 8,
|
||||||
|
"scale": [4, 8, 12, 16, 24, 32, 48, 64, 80, 96]
|
||||||
|
},
|
||||||
|
"radius": [4, 8, 12, 16],
|
||||||
|
"aesthetic": "2-5 word style description"
|
||||||
|
}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Match colors to the product personality: tech/SaaS → cool blue/indigo, creative → warm amber/coral, finance → deep navy/emerald, health → sage/teal, education → violet/sky.
|
||||||
|
- Ensure WCAG AA contrast (4.5:1) between text and background, primary and surface.
|
||||||
|
- Font pairing: heading should be distinctive (Space Grotesk, Outfit, Sora, Plus Jakarta Sans, Clash Display), body should be readable (Inter, DM Sans, Satoshi). Max 2 families.
|
||||||
|
- CJK content: if the request is in Chinese/Japanese/Korean, use "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for heading, "Inter" for body. Never use display fonts without CJK glyphs.
|
||||||
|
- Dark theme: when request mentions dark/cyber/terminal/neon/暗黑/深色, use dark background (#0F172A or #18181B), light text, brighter accents.
|
||||||
|
- Default to light theme unless explicitly asked for dark.
|
||||||
|
- Radius: 0-4 for sharp/professional, 8-12 for modern, 16+ for playful/friendly.
|
||||||
|
- Scale should have clear size jumps: [14, 16, 20, 28, 40, 56] not [14, 15, 16, 17, 18].
|
||||||
|
- Aesthetic description guides the overall feel: "clean minimal blue tech", "warm editorial amber", "bold dark neon gaming".`
|
||||||
|
|
@ -6,12 +6,15 @@
|
||||||
* The LLM correlates visual issues with actual node IDs and returns fixes.
|
* The LLM correlates visual issues with actual node IDs and returns fixes.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCanvasStore } from '@/stores/canvas-store'
|
import { nanoid } from 'nanoid'
|
||||||
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
|
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
|
||||||
import { VALIDATION_TIMEOUT_MS } from './ai-runtime-config'
|
import { VALIDATION_TIMEOUT_MS, MAX_VALIDATION_ROUNDS, VALIDATION_QUALITY_THRESHOLD } from './ai-runtime-config'
|
||||||
import type { PenNode } from '@/types/pen'
|
import type { PenNode } from '@/types/pen'
|
||||||
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
|
||||||
import type { AIProviderType } from '@/types/agent-settings'
|
import type { AIProviderType } from '@/types/agent-settings'
|
||||||
|
import { getCurrentVisualReference, clearVisualReference } from './visual-ref-orchestrator'
|
||||||
|
import { lookupIconByName } from './icon-resolver'
|
||||||
|
import { runPreValidationFixes } from './design-pre-validation'
|
||||||
|
import { captureRootFrameScreenshot } from './design-screenshot'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// System prompt for the vision validator
|
// System prompt for the vision validator
|
||||||
|
|
@ -26,37 +29,79 @@ Check for these issues:
|
||||||
3. SPACING: Uneven padding, elements too close to edges, inconsistent gaps between siblings.
|
3. SPACING: Uneven padding, elements too close to edges, inconsistent gaps between siblings.
|
||||||
4. OVERFLOW: Text or elements visually clipped or extending beyond their container.
|
4. OVERFLOW: Text or elements visually clipped or extending beyond their container.
|
||||||
5. ALIGNMENT: Elements that should be aligned but aren't (e.g. form fields not left-aligned).
|
5. ALIGNMENT: Elements that should be aligned but aren't (e.g. form fields not left-aligned).
|
||||||
6. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
|
6. TEXT CENTERING: Text that should be horizontally centered in its container but appears shifted left or right. Common in headings, buttons, divider text ("or continue with"), and footer text. Fix: ensure the parent container has alignItems="center" or the text node has width="fill_container".
|
||||||
|
7. MISSING ICONS: Path nodes that rendered as empty/invisible rectangles.
|
||||||
|
8. COLOR ISSUES: Text with poor contrast against its background, wrong background colors, inconsistent color usage across similar elements.
|
||||||
|
9. TYPOGRAPHY: Inconsistent font sizes between similar elements, wrong font weights for headings vs body text.
|
||||||
|
10. MISSING BORDERS: Input fields, cards, or containers that lack a visible border and blend into their parent background. Fix with strokeColor and strokeWidth.
|
||||||
|
11. STRUCTURAL INCONSISTENCY: Sibling elements that should follow the same pattern but have different child structures. For example, if one input field has a leading icon but a sibling input field does not, or a list item is missing an expected child element. Fix by adding the missing child node.
|
||||||
|
12. MISSING ELEMENTS: When a reference design is provided, check if important UI elements visible in the reference are missing or absent in the current design. Fix by adding the missing element as a child of the appropriate parent.
|
||||||
|
|
||||||
Output ONLY a JSON object. No explanation, no markdown fences.
|
Output ONLY a JSON object. No explanation, no markdown fences.
|
||||||
{"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}]}
|
{"qualityScore":8,"issues":["description1","description2"],"fixes":[{"nodeId":"actual-node-id","property":"width","value":"fill_container"}],"structuralFixes":[]}
|
||||||
|
|
||||||
Allowed properties and value types:
|
qualityScore: Rate the overall design quality from 1-10.
|
||||||
|
- 9-10: Production-ready, polished design
|
||||||
|
- 7-8: Good design with minor issues
|
||||||
|
- 5-6: Acceptable but needs improvement
|
||||||
|
- 1-4: Significant problems
|
||||||
|
|
||||||
|
Allowed property fixes (update existing node):
|
||||||
- width: number | "fill_container" | "fit_content"
|
- width: number | "fill_container" | "fit_content"
|
||||||
- height: number | "fill_container" | "fit_content"
|
- height: number | "fill_container" | "fit_content"
|
||||||
- padding: number | [top,right,bottom,left]
|
- padding: number | [top,right,bottom,left]
|
||||||
- gap: number
|
- gap: number
|
||||||
- fontSize: number
|
- fontSize: number
|
||||||
|
- fontWeight: number (300-900)
|
||||||
|
- letterSpacing: number
|
||||||
|
- lineHeight: number
|
||||||
- cornerRadius: number
|
- cornerRadius: number
|
||||||
- opacity: number
|
- opacity: number
|
||||||
|
- fillColor: "#hex" (background/fill color of the node)
|
||||||
|
- strokeColor: "#hex" (border/stroke color)
|
||||||
|
- strokeWidth: number (border/stroke width)
|
||||||
|
- textAlign: "left" | "center" | "right" (text horizontal alignment within its box)
|
||||||
- alignItems: "start" | "center" | "end"
|
- alignItems: "start" | "center" | "end"
|
||||||
- justifyContent: "start" | "center" | "end" | "space_between"
|
- justifyContent: "start" | "center" | "end" | "space_between"
|
||||||
|
|
||||||
|
Structural fixes (add or remove nodes — use sparingly, only for clear structural issues):
|
||||||
|
- Add child: {"action":"addChild","parentId":"real-parent-id","index":0,"node":{"type":"path","name":"KeyIcon","width":18,"height":18}}
|
||||||
|
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"text","name":"Label","content":"text","fontSize":14,"fillColor":"#hex"}}
|
||||||
|
- Add child: {"action":"addChild","parentId":"real-parent-id","node":{"type":"frame","name":"Divider","width":"fill_container","height":1,"fillColor":"#hex"}}
|
||||||
|
- Remove node: {"action":"removeNode","nodeId":"real-node-id"}
|
||||||
|
|
||||||
|
For addChild nodes:
|
||||||
|
- type: "frame" | "text" | "path" | "rectangle" | "ellipse"
|
||||||
|
- For path/icon nodes: set name to the icon name (e.g. "KeyIcon", "LockIcon", "EyeIcon"). The system resolves icon paths automatically.
|
||||||
|
- index is optional (defaults to 0 = first child). Use it to control insertion position among siblings.
|
||||||
|
- Specify width, height, fillColor as needed. Other properties are optional.
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
- Use REAL node IDs from the provided tree — never guess or fabricate IDs.
|
- Use REAL node IDs from the provided tree — never guess or fabricate IDs.
|
||||||
- For form consistency issues, fix ALL inconsistent siblings, not just one.
|
- For form consistency issues, fix ALL inconsistent siblings, not just one.
|
||||||
- If the design looks correct, return: {"issues":[],"fixes":[]}
|
- If the design looks correct, return: {"qualityScore":9,"issues":[],"fixes":[],"structuralFixes":[]}
|
||||||
- Keep fixes minimal — only fix clear visual bugs, not stylistic preferences.`
|
- Keep fixes minimal — only fix clear visual bugs, not stylistic preferences.
|
||||||
|
- Focus on the most impactful issues first.
|
||||||
|
- For structuralFixes, only add elements that are clearly needed for consistency or completeness. Do not add decorative elements unless they are present in the reference.
|
||||||
|
- CRITICAL: When using addChild, ALWAYS include companion property fixes for the parent node to maintain correct layout. For example, if the parent has justifyContent="space_between" and adding a child would break the spacing, also add a property fix to change justifyContent and/or add a gap value. Look at sibling elements with the same pattern and match the parent's layout properties to theirs.
|
||||||
|
- CRITICAL: NEVER change height or width from "fit_content" to a fixed pixel value on a frame that has layout (auto-layout). This creates empty whitespace. If a container appears invisible, fix its opacity, fill color, or border instead — not its height.`
|
||||||
|
|
||||||
// Properties that are safe to auto-fix, with allowed value types
|
// Properties that are safe to auto-fix, with allowed value types
|
||||||
const SAFE_FIX_PROPERTIES: Record<string, 'number' | 'sizing' | 'number_or_array' | 'enum_align' | 'enum_justify'> = {
|
const SAFE_FIX_PROPERTIES: Record<string, 'number' | 'sizing' | 'number_or_array' | 'enum_align' | 'enum_justify' | 'enum_text_align' | 'color' | 'font_weight'> = {
|
||||||
width: 'sizing',
|
width: 'sizing',
|
||||||
height: 'sizing',
|
height: 'sizing',
|
||||||
padding: 'number_or_array',
|
padding: 'number_or_array',
|
||||||
gap: 'number',
|
gap: 'number',
|
||||||
fontSize: 'number',
|
fontSize: 'number',
|
||||||
|
fontWeight: 'font_weight',
|
||||||
|
letterSpacing: 'number',
|
||||||
|
lineHeight: 'number',
|
||||||
cornerRadius: 'number',
|
cornerRadius: 'number',
|
||||||
opacity: 'number',
|
opacity: 'number',
|
||||||
|
fillColor: 'color',
|
||||||
|
strokeColor: 'color',
|
||||||
|
strokeWidth: 'number',
|
||||||
|
textAlign: 'enum_text_align',
|
||||||
alignItems: 'enum_align',
|
alignItems: 'enum_align',
|
||||||
justifyContent: 'enum_justify',
|
justifyContent: 'enum_justify',
|
||||||
}
|
}
|
||||||
|
|
@ -85,9 +130,26 @@ function buildNodeTreeDump(rootId: string): string {
|
||||||
if ('padding' in node && node.padding != null) props.push(`pad=${JSON.stringify(node.padding)}`)
|
if ('padding' in node && node.padding != null) props.push(`pad=${JSON.stringify(node.padding)}`)
|
||||||
if ('justifyContent' in node && node.justifyContent) props.push(`justify=${node.justifyContent}`)
|
if ('justifyContent' in node && node.justifyContent) props.push(`justify=${node.justifyContent}`)
|
||||||
if ('alignItems' in node && node.alignItems) props.push(`align=${node.alignItems}`)
|
if ('alignItems' in node && node.alignItems) props.push(`align=${node.alignItems}`)
|
||||||
if (node.type === 'text' && 'content' in node) {
|
if ('cornerRadius' in node && node.cornerRadius != null) props.push(`cr=${node.cornerRadius}`)
|
||||||
const content = (node as { content?: string }).content ?? ''
|
if ('opacity' in node && node.opacity != null && node.opacity !== 1) props.push(`opacity=${node.opacity}`)
|
||||||
props.push(`text="${content.slice(0, 30)}"`)
|
if ('fill' in node && Array.isArray(node.fill) && node.fill.length > 0) {
|
||||||
|
const firstFill = node.fill[0]
|
||||||
|
if (firstFill && 'color' in firstFill && firstFill.color) props.push(`fill="${firstFill.color}"`)
|
||||||
|
}
|
||||||
|
if ('stroke' in node && node.stroke) {
|
||||||
|
const s = node.stroke as { thickness?: number | number[]; fill?: Array<{ color?: string }> }
|
||||||
|
const strokeColor = s.fill?.[0]?.color
|
||||||
|
const strokeW = typeof s.thickness === 'number' ? s.thickness : (Array.isArray(s.thickness) ? s.thickness[0] : 0)
|
||||||
|
if (strokeColor) props.push(`stroke="${strokeColor}" strokeW=${strokeW ?? 0}`)
|
||||||
|
}
|
||||||
|
if (node.type === 'text') {
|
||||||
|
if ('fontSize' in node && node.fontSize) props.push(`fontSize=${node.fontSize}`)
|
||||||
|
if ('fontWeight' in node && node.fontWeight) props.push(`fontWeight=${node.fontWeight}`)
|
||||||
|
if ('textAlign' in node && node.textAlign) props.push(`textAlign=${node.textAlign}`)
|
||||||
|
if ('content' in node) {
|
||||||
|
const content = (node as { content?: string }).content ?? ''
|
||||||
|
props.push(`text="${content.slice(0, 30)}"`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`${indent}${props.join(' ')}`)
|
lines.push(`${indent}${props.join(' ')}`)
|
||||||
|
|
@ -104,59 +166,6 @@ function buildNodeTreeDump(rootId: string): string {
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Screenshot capture
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function captureRootFrameScreenshot(): string | null {
|
|
||||||
const canvas = useCanvasStore.getState().fabricCanvas
|
|
||||||
if (!canvas) return null
|
|
||||||
|
|
||||||
const store = useDocumentStore.getState()
|
|
||||||
const rootNode = store.getNodeById(DEFAULT_FRAME_ID)
|
|
||||||
if (!rootNode) return null
|
|
||||||
|
|
||||||
const allFlat = store.getFlatNodes()
|
|
||||||
const descendantIds = new Set<string>()
|
|
||||||
for (const node of allFlat) {
|
|
||||||
if (node.id !== DEFAULT_FRAME_ID && store.isDescendantOf(node.id, DEFAULT_FRAME_ID)) {
|
|
||||||
descendantIds.add(node.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
|
|
||||||
const rootObj = allObjects.find((obj) => obj.penNodeId === DEFAULT_FRAME_ID)
|
|
||||||
if (!rootObj) return null
|
|
||||||
|
|
||||||
const originX = rootObj.left ?? 0
|
|
||||||
const originY = rootObj.top ?? 0
|
|
||||||
const w = (rootObj.width ?? 0) * (rootObj.scaleX ?? 1)
|
|
||||||
const h = (rootObj.height ?? 0) * (rootObj.scaleY ?? 1)
|
|
||||||
|
|
||||||
if (w <= 0 || h <= 0) return null
|
|
||||||
|
|
||||||
const allIds = new Set(descendantIds)
|
|
||||||
allIds.add(DEFAULT_FRAME_ID)
|
|
||||||
|
|
||||||
const layerObjects = allObjects.filter(
|
|
||||||
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
|
|
||||||
)
|
|
||||||
|
|
||||||
const offscreen = document.createElement('canvas')
|
|
||||||
offscreen.width = Math.ceil(w)
|
|
||||||
offscreen.height = Math.ceil(h)
|
|
||||||
const ctx = offscreen.getContext('2d')
|
|
||||||
if (!ctx) return null
|
|
||||||
|
|
||||||
ctx.translate(-originX, -originY)
|
|
||||||
|
|
||||||
for (const obj of layerObjects) {
|
|
||||||
obj.render(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
return offscreen.toDataURL('image/png')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Validation API call
|
// Validation API call
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -167,9 +176,40 @@ interface ValidationFix {
|
||||||
value: number | string | number[]
|
value: number | string | number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StructuralAddChildFix {
|
||||||
|
action: 'addChild'
|
||||||
|
parentId: string
|
||||||
|
index?: number
|
||||||
|
node: {
|
||||||
|
type: 'frame' | 'text' | 'path' | 'rectangle' | 'ellipse'
|
||||||
|
name?: string
|
||||||
|
width?: number | string
|
||||||
|
height?: number | string
|
||||||
|
fillColor?: string
|
||||||
|
content?: string
|
||||||
|
fontSize?: number
|
||||||
|
fontWeight?: number
|
||||||
|
layout?: string
|
||||||
|
gap?: number
|
||||||
|
padding?: number | number[]
|
||||||
|
cornerRadius?: number
|
||||||
|
alignItems?: string
|
||||||
|
justifyContent?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StructuralRemoveNodeFix {
|
||||||
|
action: 'removeNode'
|
||||||
|
nodeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StructuralFix = StructuralAddChildFix | StructuralRemoveNodeFix
|
||||||
|
|
||||||
interface ValidationResult {
|
interface ValidationResult {
|
||||||
issues: string[]
|
issues: string[]
|
||||||
fixes: ValidationFix[]
|
fixes: ValidationFix[]
|
||||||
|
structuralFixes: StructuralFix[]
|
||||||
|
qualityScore: number
|
||||||
skipped?: boolean
|
skipped?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,9 +218,19 @@ async function validateDesignScreenshot(
|
||||||
nodeTreeDump: string,
|
nodeTreeDump: string,
|
||||||
model?: string,
|
model?: string,
|
||||||
provider?: AIProviderType,
|
provider?: AIProviderType,
|
||||||
|
referenceScreenshot?: string,
|
||||||
|
round: number = 1,
|
||||||
): Promise<ValidationResult> {
|
): Promise<ValidationResult> {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS)
|
const timeout = setTimeout(() => controller.abort(), referenceScreenshot ? VALIDATION_TIMEOUT_MS * 2 : VALIDATION_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const referenceInstruction = referenceScreenshot
|
||||||
|
? `\n\nA REFERENCE DESIGN screenshot was also provided. Compare the current design against the reference and fix any significant deviations in layout, spacing, proportions, or missing elements. The reference shows the intended design — the current screenshot should match its structure, visual balance, and element completeness. If elements visible in the reference are missing in the current design, use structuralFixes with addChild to add them.`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const roundInstruction = round > 1
|
||||||
|
? `\n\nThis is validation round ${round}. Previous fixes have already been applied. Focus on remaining issues only — do NOT re-report issues that have already been fixed.`
|
||||||
|
: ''
|
||||||
|
|
||||||
const message = `Analyze this UI design screenshot. Here is the node tree structure:
|
const message = `Analyze this UI design screenshot. Here is the node tree structure:
|
||||||
|
|
||||||
|
|
@ -188,7 +238,7 @@ async function validateDesignScreenshot(
|
||||||
${nodeTreeDump}
|
${nodeTreeDump}
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Cross-reference visual issues with the node IDs above. Return JSON fixes using real node IDs from the tree.`
|
Cross-reference visual issues with the node IDs above. Return JSON fixes using real node IDs from the tree.${referenceInstruction}${roundInstruction}`
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/ai/validate', {
|
const response = await fetch('/api/ai/validate', {
|
||||||
|
|
@ -205,23 +255,37 @@ Cross-reference visual issues with the node IDs above. Return JSON fixes using r
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return { issues: [], fixes: [], skipped: true }
|
console.warn(`[Validation] HTTP ${response.status}: ${response.statusText}`)
|
||||||
|
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json() as { text?: string; skipped?: boolean; error?: string }
|
const data = await response.json() as { text?: string; skipped?: boolean; error?: string }
|
||||||
|
|
||||||
if (data.skipped || data.error || !data.text) {
|
if (data.skipped || data.error || !data.text) {
|
||||||
return { issues: [], fixes: [], skipped: true }
|
console.warn(`[Validation] Server response:`, {
|
||||||
|
skipped: data.skipped, error: data.error, hasText: !!data.text,
|
||||||
|
provider, model,
|
||||||
|
})
|
||||||
|
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseValidationResponse(data.text)
|
const parsed = parseValidationResponse(data.text)
|
||||||
} catch {
|
if (parsed.qualityScore === 0) {
|
||||||
return { issues: [], fixes: [], skipped: true }
|
console.warn(`[Validation] qualityScore=0, raw response (first 500 chars):`, data.text.slice(0, 500))
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Validation] Fetch error:`, err)
|
||||||
|
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0, skipped: true }
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_HEX_COLOR = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
||||||
|
const VALID_FONT_WEIGHTS = new Set([100, 200, 300, 400, 500, 600, 700, 800, 900])
|
||||||
|
const VALID_TEXT_ALIGN = new Set(['left', 'center', 'right'])
|
||||||
|
|
||||||
function isValidFixValue(property: string, value: unknown): boolean {
|
function isValidFixValue(property: string, value: unknown): boolean {
|
||||||
const type = SAFE_FIX_PROPERTIES[property]
|
const type = SAFE_FIX_PROPERTIES[property]
|
||||||
if (!type) return false
|
if (!type) return false
|
||||||
|
|
@ -237,11 +301,33 @@ function isValidFixValue(property: string, value: unknown): boolean {
|
||||||
return typeof value === 'string' && VALID_ALIGN.has(value)
|
return typeof value === 'string' && VALID_ALIGN.has(value)
|
||||||
case 'enum_justify':
|
case 'enum_justify':
|
||||||
return typeof value === 'string' && VALID_JUSTIFY.has(value)
|
return typeof value === 'string' && VALID_JUSTIFY.has(value)
|
||||||
|
case 'color':
|
||||||
|
return typeof value === 'string' && VALID_HEX_COLOR.test(value)
|
||||||
|
case 'font_weight':
|
||||||
|
return typeof value === 'number' && VALID_FONT_WEIGHTS.has(value)
|
||||||
|
case 'enum_text_align':
|
||||||
|
return typeof value === 'string' && VALID_TEXT_ALIGN.has(value)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidStructuralFix(fix: unknown): fix is StructuralFix {
|
||||||
|
if (!fix || typeof fix !== 'object') return false
|
||||||
|
const f = fix as Record<string, unknown>
|
||||||
|
if (f.action === 'addChild') {
|
||||||
|
if (typeof f.parentId !== 'string' || !f.parentId) return false
|
||||||
|
if (!f.node || typeof f.node !== 'object') return false
|
||||||
|
const node = f.node as Record<string, unknown>
|
||||||
|
const validTypes = new Set(['frame', 'text', 'path', 'rectangle', 'ellipse'])
|
||||||
|
return typeof node.type === 'string' && validTypes.has(node.type)
|
||||||
|
}
|
||||||
|
if (f.action === 'removeNode') {
|
||||||
|
return typeof f.nodeId === 'string' && !!f.nodeId
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
function parseValidationResponse(text: string): ValidationResult {
|
function parseValidationResponse(text: string): ValidationResult {
|
||||||
const tryParse = (json: string): ValidationResult | null => {
|
const tryParse = (json: string): ValidationResult | null => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -250,49 +336,319 @@ function parseValidationResponse(text: string): ValidationResult {
|
||||||
parsed.fixes = parsed.fixes.filter(
|
parsed.fixes = parsed.fixes.filter(
|
||||||
(f) => f.nodeId && f.property in SAFE_FIX_PROPERTIES && isValidFixValue(f.property, f.value),
|
(f) => f.nodeId && f.property in SAFE_FIX_PROPERTIES && isValidFixValue(f.property, f.value),
|
||||||
)
|
)
|
||||||
|
// Parse and validate structural fixes
|
||||||
|
parsed.structuralFixes = Array.isArray(parsed.structuralFixes)
|
||||||
|
? parsed.structuralFixes.filter(isValidStructuralFix)
|
||||||
|
: []
|
||||||
|
const rawScore = parsed.qualityScore
|
||||||
|
const numScore = typeof rawScore === 'number' ? rawScore
|
||||||
|
: typeof rawScore === 'string' ? Number(rawScore)
|
||||||
|
: 0
|
||||||
|
parsed.qualityScore = numScore > 0
|
||||||
|
? Math.max(1, Math.min(10, Math.round(numScore)))
|
||||||
|
: 0
|
||||||
return parsed
|
return parsed
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip Agent SDK tool_use XML blocks that may precede the JSON response.
|
||||||
|
// The Agent SDK sometimes includes raw tool call XML (e.g. <tool_use>...<input>{...}</input></tool_use>)
|
||||||
|
// which confuses the JSON extraction regex.
|
||||||
|
const cleaned = text.replace(/<tool_use>[\s\S]*?<\/tool_use>/g, '').trim()
|
||||||
|
|
||||||
// Try direct parse
|
// Try direct parse
|
||||||
const direct = tryParse(text.trim())
|
const direct = tryParse(cleaned)
|
||||||
if (direct) return direct
|
if (direct) return direct
|
||||||
|
|
||||||
// Try extracting JSON from text
|
// Try extracting JSON from text
|
||||||
const match = text.match(/\{[\s\S]*\}/)
|
const match = cleaned.match(/\{[\s\S]*\}/)
|
||||||
if (match) {
|
if (match) {
|
||||||
const extracted = tryParse(match[0])
|
const extracted = tryParse(match[0])
|
||||||
if (extracted) return extracted
|
if (extracted) return extracted
|
||||||
}
|
}
|
||||||
|
|
||||||
return { issues: [], fixes: [] }
|
return { issues: [], fixes: [], structuralFixes: [], qualityScore: 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Apply fixes
|
// Apply fixes
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function applyValidationFixes(result: ValidationResult): number {
|
async function applyValidationFixes(result: ValidationResult): Promise<number> {
|
||||||
if (result.fixes.length === 0) return 0
|
const hasFixes = result.fixes.length > 0
|
||||||
|
const hasStructural = result.structuralFixes.length > 0
|
||||||
|
if (!hasFixes && !hasStructural) return 0
|
||||||
|
|
||||||
const store = useDocumentStore.getState()
|
const store = useDocumentStore.getState()
|
||||||
let applied = 0
|
let applied = 0
|
||||||
|
const skipped: string[] = []
|
||||||
|
|
||||||
|
// --- Property fixes ---
|
||||||
for (const fix of result.fixes) {
|
for (const fix of result.fixes) {
|
||||||
const node = store.getNodeById(fix.nodeId)
|
const node = store.getNodeById(fix.nodeId)
|
||||||
if (!node) continue
|
if (!node) {
|
||||||
if (!(fix.property in SAFE_FIX_PROPERTIES)) continue
|
skipped.push(`${fix.nodeId} (not found)`)
|
||||||
if (!isValidFixValue(fix.property, fix.value)) continue
|
continue
|
||||||
|
}
|
||||||
|
if (!(fix.property in SAFE_FIX_PROPERTIES)) {
|
||||||
|
skipped.push(`${fix.nodeId}.${fix.property} (unsupported property)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!isValidFixValue(fix.property, fix.value)) {
|
||||||
|
skipped.push(`${fix.nodeId}.${fix.property}=${JSON.stringify(fix.value)} (invalid value)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldValue = (node as unknown as Record<string, unknown>)[fix.property]
|
||||||
|
|
||||||
|
// Safety guard: never change fit_content → fixed pixel on layout containers.
|
||||||
|
if (
|
||||||
|
(fix.property === 'height' || fix.property === 'width') &&
|
||||||
|
typeof fix.value === 'number' &&
|
||||||
|
oldValue === 'fit_content' &&
|
||||||
|
'layout' in node && node.layout &&
|
||||||
|
'children' in node && Array.isArray(node.children) && node.children.length > 1
|
||||||
|
) {
|
||||||
|
skipped.push(`${fix.nodeId}.${fix.property} (refusing fit_content→${fix.value}px on layout container)`)
|
||||||
|
console.warn(`[Validation] Blocked: ${fix.nodeId}.${fix.property} fit_content→${fix.value}px (layout container with ${node.children.length} children)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// fillColor is a virtual property — translate to PenFill array
|
||||||
|
if (fix.property === 'fillColor' && typeof fix.value === 'string') {
|
||||||
|
store.updateNode(fix.nodeId, { fill: [{ type: 'solid', color: fix.value }] })
|
||||||
|
console.log(`[Validation Fix] ${fix.nodeId}: fill → ${fix.value}`)
|
||||||
|
applied++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// strokeColor — translate to PenStroke
|
||||||
|
if (fix.property === 'strokeColor' && typeof fix.value === 'string') {
|
||||||
|
const existingNode = store.getNodeById(fix.nodeId)
|
||||||
|
const existingStroke = existingNode && 'stroke' in existingNode ? existingNode.stroke : undefined
|
||||||
|
const thickness = existingStroke && 'thickness' in existingStroke ? (existingStroke as { thickness?: number }).thickness ?? 1 : 1
|
||||||
|
store.updateNode(fix.nodeId, {
|
||||||
|
stroke: { thickness, fill: [{ type: 'solid', color: fix.value }] },
|
||||||
|
})
|
||||||
|
console.log(`[Validation Fix] ${fix.nodeId}: strokeColor → ${fix.value}`)
|
||||||
|
applied++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// strokeWidth — update thickness in existing stroke or create new stroke
|
||||||
|
if (fix.property === 'strokeWidth' && typeof fix.value === 'number') {
|
||||||
|
const existingNode = store.getNodeById(fix.nodeId)
|
||||||
|
const existingStroke = existingNode && 'stroke' in existingNode ? existingNode.stroke : undefined
|
||||||
|
const color = existingStroke && 'fill' in (existingStroke as object)
|
||||||
|
? ((existingStroke as { fill?: Array<{ color?: string }> }).fill?.[0]?.color ?? '#CBD5E1')
|
||||||
|
: '#CBD5E1'
|
||||||
|
store.updateNode(fix.nodeId, {
|
||||||
|
stroke: { thickness: fix.value, fill: [{ type: 'solid', color }] },
|
||||||
|
})
|
||||||
|
console.log(`[Validation Fix] ${fix.nodeId}: strokeWidth → ${fix.value}`)
|
||||||
|
applied++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
store.updateNode(fix.nodeId, { [fix.property]: fix.value })
|
store.updateNode(fix.nodeId, { [fix.property]: fix.value })
|
||||||
|
console.log(`[Validation Fix] ${fix.nodeId}: ${fix.property} ${JSON.stringify(oldValue)} → ${JSON.stringify(fix.value)}`)
|
||||||
applied++
|
applied++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Structural fixes ---
|
||||||
|
for (const sf of result.structuralFixes) {
|
||||||
|
if (sf.action === 'addChild') {
|
||||||
|
const parent = store.getNodeById(sf.parentId)
|
||||||
|
if (!parent) {
|
||||||
|
skipped.push(`addChild: parent ${sf.parentId} not found`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const newNode = await buildNodeFromSpec(sf.node)
|
||||||
|
if (!newNode) {
|
||||||
|
skipped.push(`addChild: could not build node for ${sf.node.type}:${sf.node.name ?? '?'}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
store.addNode(sf.parentId, newNode, sf.index)
|
||||||
|
console.log(`[Validation Fix] addChild: ${newNode.type}:${newNode.name ?? newNode.id} → parent ${sf.parentId} at index ${sf.index ?? 0}`)
|
||||||
|
applied++
|
||||||
|
|
||||||
|
// Auto-fix parent layout if addChild might break it.
|
||||||
|
// When adding a child to a space_between parent, look for a sibling
|
||||||
|
// with the same pattern and copy its layout properties.
|
||||||
|
autoFixParentLayoutAfterAddChild(store, sf.parentId, parent)
|
||||||
|
} else if (sf.action === 'removeNode') {
|
||||||
|
const node = store.getNodeById(sf.nodeId)
|
||||||
|
if (!node) {
|
||||||
|
skipped.push(`removeNode: ${sf.nodeId} not found`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
store.removeNode(sf.nodeId)
|
||||||
|
console.log(`[Validation Fix] removeNode: ${sf.nodeId}`)
|
||||||
|
applied++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped.length > 0) {
|
||||||
|
console.warn(`[Validation] Skipped fixes:`, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
return applied
|
return applied
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a PenNode from a structural fix spec.
|
||||||
|
* For path nodes with icon-like names, resolves via the local icon registry.
|
||||||
|
*/
|
||||||
|
async function buildNodeFromSpec(
|
||||||
|
spec: StructuralAddChildFix['node'],
|
||||||
|
): Promise<PenNode | null> {
|
||||||
|
const id = nanoid(8)
|
||||||
|
const node: Record<string, unknown> = {
|
||||||
|
id,
|
||||||
|
type: spec.type,
|
||||||
|
name: spec.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
if (spec.width != null) node.width = spec.width
|
||||||
|
if (spec.height != null) node.height = spec.height
|
||||||
|
if (spec.cornerRadius != null) node.cornerRadius = spec.cornerRadius
|
||||||
|
if (spec.layout) node.layout = spec.layout
|
||||||
|
if (spec.gap != null) node.gap = spec.gap
|
||||||
|
if (spec.padding != null) node.padding = spec.padding
|
||||||
|
if (spec.alignItems) node.alignItems = spec.alignItems
|
||||||
|
if (spec.justifyContent) node.justifyContent = spec.justifyContent
|
||||||
|
|
||||||
|
// Fill color
|
||||||
|
if (spec.fillColor && VALID_HEX_COLOR.test(spec.fillColor)) {
|
||||||
|
node.fill = [{ type: 'solid', color: spec.fillColor }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text-specific
|
||||||
|
if (spec.type === 'text') {
|
||||||
|
if (spec.content) node.content = spec.content
|
||||||
|
if (spec.fontSize) node.fontSize = spec.fontSize
|
||||||
|
if (spec.fontWeight) node.fontWeight = spec.fontWeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path/icon — resolve icon path data
|
||||||
|
if (spec.type === 'path' && spec.name) {
|
||||||
|
const color = spec.fillColor && VALID_HEX_COLOR.test(spec.fillColor) ? spec.fillColor : '#64748B'
|
||||||
|
const icon = lookupIconByName(spec.name)
|
||||||
|
if (icon) {
|
||||||
|
node.d = icon.d
|
||||||
|
node.iconId = icon.iconId
|
||||||
|
if (icon.style === 'stroke') {
|
||||||
|
node.stroke = { thickness: 2, fill: [{ type: 'solid', color }] }
|
||||||
|
node.fill = []
|
||||||
|
} else {
|
||||||
|
node.fill = [{ type: 'solid', color }]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try server-side resolution
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ai/icon?name=${encodeURIComponent(spec.name)}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.icon) {
|
||||||
|
node.d = data.icon.d
|
||||||
|
node.iconId = data.icon.iconId
|
||||||
|
if (data.icon.style === 'stroke') {
|
||||||
|
node.stroke = { thickness: 2, fill: [{ type: 'solid', color }] }
|
||||||
|
node.fill = []
|
||||||
|
} else {
|
||||||
|
node.fill = [{ type: 'solid', color }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.warn(`[Validation] Icon resolution failed for ${spec.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default dimensions for icons if not specified
|
||||||
|
if (!spec.width) node.width = 18
|
||||||
|
if (!spec.height) node.height = 18
|
||||||
|
}
|
||||||
|
|
||||||
|
return node as unknown as PenNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After adding a child, auto-fix parent layout if needed.
|
||||||
|
* Searches the tree for structurally equivalent nodes (same type, layout,
|
||||||
|
* similar name pattern) and copies their layout properties.
|
||||||
|
*/
|
||||||
|
function autoFixParentLayoutAfterAddChild(
|
||||||
|
store: ReturnType<typeof useDocumentStore.getState>,
|
||||||
|
parentId: string,
|
||||||
|
parentBeforeAdd: PenNode,
|
||||||
|
): void {
|
||||||
|
const parentNode = parentBeforeAdd as unknown as Record<string, unknown>
|
||||||
|
const justify = parentNode.justifyContent as string | undefined
|
||||||
|
if (!justify || justify === 'start') return // already fine
|
||||||
|
|
||||||
|
// Search the full flat node list for a structural equivalent:
|
||||||
|
// same type, same layout direction, similar name pattern, but different justify
|
||||||
|
const flatNodes = store.getFlatNodes()
|
||||||
|
const parentNameBase = extractNameBase(parentBeforeAdd.name ?? '')
|
||||||
|
|
||||||
|
// Get current parent (after child was added) to compare child counts
|
||||||
|
const currentParent = store.getNodeById(parentId)
|
||||||
|
const currentChildCount = currentParent && 'children' in currentParent
|
||||||
|
? (currentParent.children?.length ?? 0)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
for (const candidate of flatNodes) {
|
||||||
|
if (candidate.id === parentId) continue
|
||||||
|
if (candidate.type !== parentBeforeAdd.type) continue
|
||||||
|
|
||||||
|
const cand = candidate as unknown as Record<string, unknown>
|
||||||
|
if (cand.layout !== parentNode.layout) continue
|
||||||
|
|
||||||
|
// Check name similarity (e.g. "Email Input" vs "Password Input" share "Input")
|
||||||
|
const candNameBase = extractNameBase(candidate.name ?? '')
|
||||||
|
if (!parentNameBase || !candNameBase || parentNameBase !== candNameBase) continue
|
||||||
|
|
||||||
|
// Skip if the target parent now has more children than the candidate.
|
||||||
|
// The candidate's layout was designed for fewer children and may not
|
||||||
|
// be appropriate (e.g. copying "start" from a 2-child sibling would
|
||||||
|
// break a 3-child node that needs "space_between" for a trailing icon).
|
||||||
|
const candChildCount = 'children' in candidate
|
||||||
|
? ((candidate as { children?: unknown[] }).children?.length ?? 0)
|
||||||
|
: 0
|
||||||
|
if (currentChildCount > candChildCount) {
|
||||||
|
console.log(`[Validation Fix] autoFixParentLayout: skipped ${parentId} — has ${currentChildCount} children vs candidate ${candidate.id} with ${candChildCount}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found a structural equivalent with same or more children — copy its justify and gap
|
||||||
|
const candJustify = cand.justifyContent as string | undefined
|
||||||
|
const candGap = cand.gap as number | undefined
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {}
|
||||||
|
if ((candJustify ?? 'start') !== justify) {
|
||||||
|
updates.justifyContent = candJustify ?? 'start'
|
||||||
|
}
|
||||||
|
if (candGap != null && candGap !== parentNode.gap) {
|
||||||
|
updates.gap = candGap
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
store.updateNode(parentId, updates)
|
||||||
|
console.log(`[Validation Fix] autoFixParentLayout: ${parentId} matched ${candidate.id} →`, updates)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the last word of a node name as a structural key (e.g. "Email Input" → "input") */
|
||||||
|
function extractNameBase(name: string): string {
|
||||||
|
const words = name.trim().toLowerCase().split(/\s+/)
|
||||||
|
return words.length > 0 ? words[words.length - 1] : ''
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public orchestration
|
// Public orchestration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -304,52 +660,175 @@ export async function runPostGenerationValidation(
|
||||||
provider?: AIProviderType
|
provider?: AIProviderType
|
||||||
},
|
},
|
||||||
): Promise<{ applied: number; skipped: boolean }> {
|
): Promise<{ applied: number; skipped: boolean }> {
|
||||||
options?.onStatusUpdate?.('streaming', 'Capturing screenshot...')
|
let totalApplied = 0
|
||||||
|
let lastQualityScore = 0
|
||||||
|
const fixHistory = new Map<string, number>() // "nodeId:property" → round count
|
||||||
|
|
||||||
// Wait for canvas render to stabilize
|
// Accumulate a log so the final status retains all validation steps
|
||||||
await new Promise<void>((resolve) => {
|
const log: string[] = []
|
||||||
requestAnimationFrame(() => {
|
function emit(status: 'pending' | 'streaming' | 'done' | 'error', line?: string) {
|
||||||
requestAnimationFrame(() => resolve())
|
if (line) log.push(line)
|
||||||
|
options?.onStatusUpdate?.(status, log.join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-validation: pure code checks (no LLM needed)
|
||||||
|
emit('streaming', '[pending] Running pre-checks...')
|
||||||
|
const preFixCount = runPreValidationFixes()
|
||||||
|
if (preFixCount > 0) {
|
||||||
|
totalApplied += preFixCount
|
||||||
|
log[log.length - 1] = `[done] Pre-checks: fixed ${preFixCount} issue${preFixCount > 1 ? 's' : ''}`
|
||||||
|
} else {
|
||||||
|
log[log.length - 1] = '[done] Pre-checks: OK'
|
||||||
|
}
|
||||||
|
emit('streaming')
|
||||||
|
|
||||||
|
for (let round = 1; round <= MAX_VALIDATION_ROUNDS; round++) {
|
||||||
|
const isFirstRound = round === 1
|
||||||
|
|
||||||
|
emit('streaming',
|
||||||
|
isFirstRound ? '[pending] Capturing screenshot...' : `[pending] Re-capturing screenshot (round ${round})...`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait for canvas render to stabilize.
|
||||||
|
// After applying fixes (round 2+), the Zustand → canvas sync pipeline needs
|
||||||
|
// more time: subscribe fires → flattenNodes → computeLayout → Fabric render.
|
||||||
|
// Use a longer delay for subsequent rounds to ensure fixes are rendered.
|
||||||
|
if (round > 1) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 500))
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const imageBase64 = captureRootFrameScreenshot()
|
const imageBase64 = captureRootFrameScreenshot()
|
||||||
if (!imageBase64) {
|
if (!imageBase64) {
|
||||||
console.warn('[Validation] Could not capture screenshot — skipping')
|
console.warn(`[Validation] Round ${round}: could not capture screenshot — stopping`)
|
||||||
options?.onStatusUpdate?.('done', 'Skipped (no screenshot)')
|
if (isFirstRound) {
|
||||||
return { applied: 0, skipped: true }
|
emit('done', '[error] Screenshot failed')
|
||||||
|
clearVisualReference()
|
||||||
|
return { applied: 0, skipped: true }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the "Capturing..." line with success
|
||||||
|
log[log.length - 1] = isFirstRound ? '[done] Screenshot captured' : `[done] Screenshot captured (round ${round})`
|
||||||
|
emit('streaming')
|
||||||
|
|
||||||
|
const nodeTreeDump = buildNodeTreeDump(DEFAULT_FRAME_ID)
|
||||||
|
if (isFirstRound) {
|
||||||
|
console.log(`[Validation] Node tree dump:\n${nodeTreeDump}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference comparison only on first round
|
||||||
|
const visualRef = isFirstRound ? getCurrentVisualReference() : null
|
||||||
|
const hasReference = visualRef?.screenshot && visualRef.screenshot.length > 0
|
||||||
|
|
||||||
|
emit('streaming',
|
||||||
|
hasReference && isFirstRound
|
||||||
|
? '[pending] Comparing with design reference...'
|
||||||
|
: isFirstRound ? '[pending] Analyzing design...' : `[pending] Analyzing (round ${round})...`,
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await validateDesignScreenshot(
|
||||||
|
imageBase64,
|
||||||
|
nodeTreeDump,
|
||||||
|
options?.model,
|
||||||
|
options?.provider,
|
||||||
|
hasReference ? visualRef!.screenshot : undefined,
|
||||||
|
round,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.skipped) {
|
||||||
|
console.log(`[Validation] Round ${round}: skipped (see warnings above for details; provider=${options?.provider}, model=${options?.model})`)
|
||||||
|
// Replace "Analyzing..." with skipped reason
|
||||||
|
log[log.length - 1] = '[error] Analysis skipped (timeout or provider error)'
|
||||||
|
if (isFirstRound) {
|
||||||
|
clearVisualReference()
|
||||||
|
emit('done')
|
||||||
|
return { applied: 0, skipped: true }
|
||||||
|
}
|
||||||
|
emit('streaming')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.qualityScore > 0) {
|
||||||
|
lastQualityScore = result.qualityScore
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace "Analyzing..." with result
|
||||||
|
const scoreLabel = result.qualityScore > 0 ? ` (quality: ${result.qualityScore}/10)` : ''
|
||||||
|
if (result.qualityScore === 0 && result.issues.length === 0) {
|
||||||
|
// Parsing failed — not a clean pass
|
||||||
|
log[log.length - 1] = `[error] Analysis incomplete (round ${round})`
|
||||||
|
console.warn(`[Validation] Round ${round}: qualityScore=0 with no issues — likely parse failure`)
|
||||||
|
break
|
||||||
|
} else if (result.issues.length > 0) {
|
||||||
|
log[log.length - 1] = `[done] Found ${result.issues.length} issue${result.issues.length > 1 ? 's' : ''}${scoreLabel}`
|
||||||
|
console.log(`[Validation] Round ${round}: issues found:`, result.issues)
|
||||||
|
} else {
|
||||||
|
log[log.length - 1] = `[done] No issues found${scoreLabel}`
|
||||||
|
}
|
||||||
|
emit('streaming')
|
||||||
|
|
||||||
|
// Quality threshold reached — design is good enough
|
||||||
|
if (result.qualityScore >= VALIDATION_QUALITY_THRESHOLD) {
|
||||||
|
console.log(`[Validation] Round ${round}: quality ${result.qualityScore}/10 >= threshold, stopping`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track fixes for repeated-fix detection
|
||||||
|
for (const f of result.fixes) {
|
||||||
|
const key = `${f.nodeId}:${f.property}`
|
||||||
|
fixHistory.set(key, (fixHistory.get(key) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out repeated fixes that already failed in previous rounds
|
||||||
|
if (round > 1) {
|
||||||
|
const preFilterLen = result.fixes.length
|
||||||
|
result.fixes = result.fixes.filter(f => (fixHistory.get(`${f.nodeId}:${f.property}`) ?? 0) <= 1)
|
||||||
|
if (result.fixes.length < preFilterLen) {
|
||||||
|
console.log(`[Validation] Round ${round}: filtered ${preFilterLen - result.fixes.length} repeated fix(es)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.fixes.length === 0 && result.structuralFixes.length === 0) {
|
||||||
|
console.log(`[Validation] Round ${round}: no fixes needed`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalFixCount = result.fixes.length + result.structuralFixes.length
|
||||||
|
emit('streaming', `[pending] Applying ${totalFixCount} fix${totalFixCount > 1 ? 'es' : ''}...`)
|
||||||
|
|
||||||
|
const applied = await applyValidationFixes(result)
|
||||||
|
totalApplied += applied
|
||||||
|
console.log(`[Validation] Round ${round}: applied ${applied} fixes (quality: ${result.qualityScore}/10):`, result.fixes, result.structuralFixes)
|
||||||
|
|
||||||
|
// Replace "Applying..." with result
|
||||||
|
if (applied > 0) {
|
||||||
|
log[log.length - 1] = `[done] Applied ${applied} fix${applied > 1 ? 'es' : ''}`
|
||||||
|
} else {
|
||||||
|
log[log.length - 1] = '[error] No fixes could be applied'
|
||||||
|
console.log(`[Validation] Round ${round}: no fixes could be applied, stopping`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
emit('streaming')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build simplified node tree for LLM context
|
// Cleanup visual reference after all rounds
|
||||||
const nodeTreeDump = buildNodeTreeDump(DEFAULT_FRAME_ID)
|
clearVisualReference()
|
||||||
|
|
||||||
options?.onStatusUpdate?.('streaming', 'Analyzing design...')
|
// Final summary line
|
||||||
const result = await validateDesignScreenshot(
|
const qualityInfo = lastQualityScore > 0 ? ` — quality: ${lastQualityScore}/10` : ''
|
||||||
imageBase64,
|
if (totalApplied > 0) {
|
||||||
nodeTreeDump,
|
emit('done', `[done] Done: ${totalApplied} fix${totalApplied > 1 ? 'es' : ''} applied${qualityInfo}`)
|
||||||
options?.model,
|
} else if (lastQualityScore > 0) {
|
||||||
options?.provider,
|
emit('done', `[done] Done: no fixes needed${qualityInfo}`)
|
||||||
)
|
} else {
|
||||||
|
emit('done')
|
||||||
if (result.skipped) {
|
|
||||||
console.log('[Validation] Skipped (provider unsupported)')
|
|
||||||
options?.onStatusUpdate?.('done', 'Skipped')
|
|
||||||
return { applied: 0, skipped: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.issues.length > 0) {
|
return { applied: totalApplied, skipped: false }
|
||||||
console.log('[Validation] Issues found:', result.issues)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.fixes.length === 0) {
|
|
||||||
console.log('[Validation] No fixes needed')
|
|
||||||
options?.onStatusUpdate?.('done', 'No issues found')
|
|
||||||
return { applied: 0, skipped: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
options?.onStatusUpdate?.('streaming', `Applying ${result.fixes.length} fixes...`)
|
|
||||||
const applied = applyValidationFixes(result)
|
|
||||||
console.log(`[Validation] Applied ${applied} fixes:`, result.fixes)
|
|
||||||
options?.onStatusUpdate?.('done', `Applied ${applied} fixes`)
|
|
||||||
return { applied, skipped: false }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import type { PenNode } from '@/types/pen'
|
import type { PenNode } from '@/types/pen'
|
||||||
|
import {
|
||||||
|
defaultLineHeight,
|
||||||
|
estimateLineWidth,
|
||||||
|
hasCjkText as _hasCjkText,
|
||||||
|
} from '@/canvas/canvas-text-measure'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Pure utility functions extracted from design-generator.ts
|
// Pure utility functions extracted from design-generator.ts
|
||||||
|
|
@ -93,50 +98,6 @@ export function parsePaddingValues(
|
||||||
// Text measurement utilities
|
// Text measurement utilities
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function estimateWrappingLineWidth(
|
|
||||||
text: string,
|
|
||||||
fontSize: number,
|
|
||||||
): number {
|
|
||||||
let width = 0
|
|
||||||
for (const char of text) {
|
|
||||||
if (
|
|
||||||
/[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF\uFE30-\uFE4F]/.test(
|
|
||||||
char,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
width += fontSize * 1.12
|
|
||||||
} else if (char === ' ') {
|
|
||||||
width += fontSize * 0.3
|
|
||||||
} else if (/[A-Z]/.test(char)) {
|
|
||||||
width += fontSize * 0.72
|
|
||||||
} else {
|
|
||||||
width += fontSize * 0.58
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateSingleLineTextWidth(
|
|
||||||
text: string,
|
|
||||||
fontSize: number,
|
|
||||||
): number {
|
|
||||||
let width = 0
|
|
||||||
for (const char of text) {
|
|
||||||
if (
|
|
||||||
/[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF\uFE30-\uFE4F]/.test(
|
|
||||||
char,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
width += fontSize * 1.35
|
|
||||||
} else if (char === ' ') {
|
|
||||||
width += fontSize * 0.3
|
|
||||||
} else {
|
|
||||||
width += fontSize * 0.6
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Estimate auto-height for text with wrapping.
|
* Estimate auto-height for text with wrapping.
|
||||||
* `canvasWidth` is used as fallback when parentContentWidth is unavailable.
|
* `canvasWidth` is used as fallback when parentContentWidth is unavailable.
|
||||||
|
|
@ -148,7 +109,7 @@ export function estimateAutoHeight(
|
||||||
parentContentWidth?: number,
|
parentContentWidth?: number,
|
||||||
canvasWidth = 1200,
|
canvasWidth = 1200,
|
||||||
): number {
|
): number {
|
||||||
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F]/.test(text)
|
const hasCjk = _hasCjkText(text)
|
||||||
const minLh = hasCjk
|
const minLh = hasCjk
|
||||||
? fontSize >= 28
|
? fontSize >= 28
|
||||||
? 1.28
|
? 1.28
|
||||||
|
|
@ -167,7 +128,10 @@ export function estimateAutoHeight(
|
||||||
|
|
||||||
const logicalLines = text.split(/\r?\n/)
|
const logicalLines = text.split(/\r?\n/)
|
||||||
const wrappedLineCount = logicalLines.reduce((sum, line) => {
|
const wrappedLineCount = logicalLines.reduce((sum, line) => {
|
||||||
const lineWidth = estimateWrappingLineWidth(line, fontSize)
|
// Use the canonical estimateLineWidth from canvas-text-measure with
|
||||||
|
// a safety factor to avoid underestimating wrapping.
|
||||||
|
const safetyW = _hasCjkText(line) ? 1.06 : 1.14
|
||||||
|
const lineWidth = estimateLineWidth(line, fontSize) * safetyW
|
||||||
return sum + Math.max(1, Math.ceil(lineWidth / availW))
|
return sum + Math.max(1, Math.ceil(lineWidth / availW))
|
||||||
}, 0)
|
}, 0)
|
||||||
const safetyMargin = fontSize >= 28 ? 1.15 : 1.1
|
const safetyMargin = fontSize >= 28 ? 1.15 : 1.1
|
||||||
|
|
@ -192,7 +156,7 @@ export function estimateNodeIntrinsicHeight(
|
||||||
let textHeight = 0
|
let textHeight = 0
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
const fs = node.fontSize ?? 16
|
const fs = node.fontSize ?? 16
|
||||||
const lh = node.lineHeight ?? (fs >= 28 ? 1.2 : 1.5)
|
const lh = node.lineHeight ?? defaultLineHeight(fs)
|
||||||
if (
|
if (
|
||||||
typeof node.content === 'string' &&
|
typeof node.content === 'string' &&
|
||||||
node.content.trim() &&
|
node.content.trim() &&
|
||||||
|
|
@ -353,9 +317,9 @@ export function getTextContentForNode(node: PenNode): string {
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasCjkText(text: string): boolean {
|
// Re-export canonical hasCjkText from canvas-text-measure so existing
|
||||||
return /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(text)
|
// importers (role-resolver, typography roles) continue to work.
|
||||||
}
|
export const hasCjkText = _hasCjkText
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Phone placeholder SVG
|
// Phone placeholder SVG
|
||||||
|
|
|
||||||
127
src/services/ai/html-renderer.ts
Normal file
127
src/services/ai/html-renderer.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
/**
|
||||||
|
* HTML Renderer (Stage 2 of visual reference pipeline).
|
||||||
|
*
|
||||||
|
* Renders generated HTML/CSS to a screenshot using a hidden iframe + html2canvas.
|
||||||
|
* Runs entirely client-side — no external browser process needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an HTML string to a base64 PNG screenshot.
|
||||||
|
* Creates a hidden iframe, writes the HTML, and captures with html2canvas.
|
||||||
|
*
|
||||||
|
* @param html - Complete HTML document string
|
||||||
|
* @param width - Viewport width in pixels
|
||||||
|
* @param height - Viewport height in pixels (0 = auto based on content)
|
||||||
|
* @returns Base64 PNG string (without data: URL prefix)
|
||||||
|
*/
|
||||||
|
export async function renderHtmlToScreenshot(
|
||||||
|
html: string,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): Promise<string> {
|
||||||
|
// Safety check — only runs in browser
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
throw new Error('renderHtmlToScreenshot requires a browser environment')
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Position off-screen
|
||||||
|
iframe.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
width: ${width}px;
|
||||||
|
height: ${height > 0 ? `${height}px` : '4000px'};
|
||||||
|
border: none;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
`
|
||||||
|
document.body.appendChild(iframe)
|
||||||
|
|
||||||
|
const iframeDoc = iframe.contentDocument
|
||||||
|
if (!iframeDoc) {
|
||||||
|
throw new Error('Could not access iframe document')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the HTML into the iframe (same-origin blob)
|
||||||
|
iframeDoc.open()
|
||||||
|
iframeDoc.write(html)
|
||||||
|
iframeDoc.close()
|
||||||
|
|
||||||
|
// Wait for fonts and rendering to settle
|
||||||
|
await waitForRender(iframeDoc)
|
||||||
|
|
||||||
|
// Determine actual content height if height was auto
|
||||||
|
const captureHeight = height > 0
|
||||||
|
? height
|
||||||
|
: Math.min(iframeDoc.body.scrollHeight || 4000, 6000)
|
||||||
|
|
||||||
|
// Resize iframe to actual content height
|
||||||
|
if (height <= 0) {
|
||||||
|
iframe.style.height = `${captureHeight}px`
|
||||||
|
// Wait one more frame for resize to apply
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture with html2canvas
|
||||||
|
const canvas = await html2canvas(iframeDoc.body, {
|
||||||
|
width,
|
||||||
|
height: captureHeight,
|
||||||
|
windowWidth: width,
|
||||||
|
windowHeight: captureHeight,
|
||||||
|
useCORS: true,
|
||||||
|
allowTaint: true,
|
||||||
|
scale: 1, // 1x is sufficient for reference (saves memory/bandwidth)
|
||||||
|
logging: false,
|
||||||
|
backgroundColor: null, // Preserve transparency
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert to base64 PNG (strip the data:image/png;base64, prefix)
|
||||||
|
const dataUrl = canvas.toDataURL('image/png')
|
||||||
|
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
|
||||||
|
|
||||||
|
return base64
|
||||||
|
} finally {
|
||||||
|
// Cleanup
|
||||||
|
if (iframe.parentNode) {
|
||||||
|
document.body.removeChild(iframe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the iframe document to finish rendering.
|
||||||
|
* Waits for fonts, images, and layout to stabilize.
|
||||||
|
*/
|
||||||
|
async function waitForRender(doc: Document): Promise<void> {
|
||||||
|
// Wait for fonts to load (if the document's fonts API is available)
|
||||||
|
try {
|
||||||
|
if (doc.fonts && typeof doc.fonts.ready === 'object') {
|
||||||
|
await Promise.race([
|
||||||
|
doc.fonts.ready,
|
||||||
|
new Promise<void>((r) => setTimeout(r, 3000)), // Max 3s for fonts
|
||||||
|
])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fonts API not available in iframe — continue anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for general rendering to stabilize (2 animation frames + small delay)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 300) // 300ms for CSS transitions and layout
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,9 @@ export function buildFinalStepTags(
|
||||||
const entry = progress.subtasks[i]
|
const entry = progress.subtasks[i]
|
||||||
const status = entry.status
|
const status = entry.status
|
||||||
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
|
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
|
||||||
return `<step title="${st.label}${nodeInfo}" status="${status}"></step>`
|
// Preserve thinking content so validation details remain visible after streaming
|
||||||
|
const thinkingContent = entry.thinking ?? ''
|
||||||
|
return `<step title="${st.label}${nodeInfo}" status="${status}">${thinkingContent}</step>`
|
||||||
})
|
})
|
||||||
.join('\n')
|
.join('\n')
|
||||||
return `${planningStep}\n${subtaskSteps}`
|
return `${planningStep}\n${subtaskSteps}`
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
PROMPT_OPTIMIZER_LIMITS,
|
PROMPT_OPTIMIZER_LIMITS,
|
||||||
SUB_AGENT_TIMEOUT_PROFILES,
|
SUB_AGENT_TIMEOUT_PROFILES,
|
||||||
} from './ai-runtime-config'
|
} from './ai-runtime-config'
|
||||||
|
import { getAllPrinciples } from './design-principles'
|
||||||
|
|
||||||
export interface PreparedDesignPrompt {
|
export interface PreparedDesignPrompt {
|
||||||
original: string
|
original: string
|
||||||
|
|
@ -12,6 +13,8 @@ export interface PreparedDesignPrompt {
|
||||||
subAgentPrompt: string
|
subAgentPrompt: string
|
||||||
wasCompressed: boolean
|
wasCompressed: boolean
|
||||||
originalLength: number
|
originalLength: number
|
||||||
|
/** Selectively loaded design principles for sub-agent context */
|
||||||
|
designPrinciples: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSubAgentTimeouts(promptLength: number): {
|
export function getSubAgentTimeouts(promptLength: number): {
|
||||||
|
|
@ -64,6 +67,7 @@ export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
|
||||||
subAgentPrompt: truncateByCharCount(normalized, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
|
subAgentPrompt: truncateByCharCount(normalized, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
|
||||||
wasCompressed: normalized.length > PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator,
|
wasCompressed: normalized.length > PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator,
|
||||||
originalLength: normalized.length,
|
originalLength: normalized.length,
|
||||||
|
designPrinciples: getAllPrinciples(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,19 @@
|
||||||
export const ORCHESTRATOR_PROMPT = `Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
|
export const ORCHESTRATOR_PROMPT = `Split a UI request into cohesive subtasks. Each subtask = a meaningful UI section or component group. Output ONLY JSON, start with {.
|
||||||
|
|
||||||
DESIGN TYPE DETECTION:
|
DESIGN TYPE DETECTION:
|
||||||
First determine the design type from the user's request:
|
Classify by the design's PURPOSE — reason about intent, do not keyword-match:
|
||||||
- "landing page" / "website" / "官网" / "首页" → Landing Page (multi-section scrollable page, 6-10 subtasks)
|
|
||||||
- "login" / "signup" / "register" / "登录" / "注册" → App Screen (single screen, 1-4 subtasks)
|
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
|
||||||
- "dashboard" / "settings" / "profile" / "设置" / "个人中心" → App Screen (single screen, 2-5 subtasks)
|
→ Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
|
||||||
- "form" / "表单" / "checkout" / "结算" → App Screen (single screen, 1-4 subtasks)
|
→ Structure: navigation → hero → content sections → CTA → footer
|
||||||
- Other app screens (modal, dialog, onboarding, etc.) → App Screen (1-5 subtasks)
|
|
||||||
|
2. Single-task screen — functional UI focused on one user task (e.g. authentication, forms, settings, profiles, modals, onboarding):
|
||||||
|
→ Mobile: width=375, height=812 (fixed viewport), 1-5 subtasks
|
||||||
|
→ Structure: header + focused content area only, no navigation/hero/footer
|
||||||
|
|
||||||
|
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
|
||||||
|
→ Desktop: width=1200, height=0, 2-5 subtasks
|
||||||
|
→ Structure: sidebar or topbar + content panels
|
||||||
|
|
||||||
FORMAT:
|
FORMAT:
|
||||||
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#F8FAFC"}]},"styleGuide":{"palette":{"background":"#F8FAFC","surface":"#FFFFFF","text":"#0F172A","secondary":"#64748B","accent":"#2563EB","accent2":"#0EA5E9","border":"#E2E8F0"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"clean modern with blue accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","elements":"logo, nav links (Home, Features, Pricing, Blog), sign-in button, get-started CTA button","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","elements":"headline, subtitle, CTA button, hero illustration or phone mockup","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","elements":"section title, 3 feature cards each with icon + title + description","region":{"width":1200,"height":480}}]}
|
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#F8FAFC"}]},"styleGuide":{"palette":{"background":"#F8FAFC","surface":"#FFFFFF","text":"#0F172A","secondary":"#64748B","accent":"#2563EB","accent2":"#0EA5E9","border":"#E2E8F0"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"clean modern with blue accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","elements":"logo, nav links (Home, Features, Pricing, Blog), sign-in button, get-started CTA button","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","elements":"headline, subtitle, CTA button, hero illustration or phone mockup","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","elements":"section title, 3 feature cards each with icon + title + description","region":{"width":1200,"height":480}}]}
|
||||||
|
|
@ -20,12 +27,12 @@ RULES:
|
||||||
- ELEMENT BOUNDARIES: Each subtask MUST have an "elements" field listing the specific UI elements it contains. Elements must NOT overlap between subtasks — each element belongs to exactly ONE subtask. Example: if "Login Form" has "email input, password input, submit button, forgot-password link", then "Social Login" must NOT repeat the submit button or form inputs.
|
- ELEMENT BOUNDARIES: Each subtask MUST have an "elements" field listing the specific UI elements it contains. Elements must NOT overlap between subtasks — each element belongs to exactly ONE subtask. Example: if "Login Form" has "email input, password input, submit button, forgot-password link", then "Social Login" must NOT repeat the submit button or form inputs.
|
||||||
- STYLE SELECTION: Choose light or dark theme based on user intent. Dark: user mentions dark/cyber/terminal/neon/夜间/暗黑/deep/gaming/noir. Light (default): all other cases — SaaS, marketing, education, e-commerce, productivity, social. Never default to dark unless the content clearly calls for it.
|
- STYLE SELECTION: Choose light or dark theme based on user intent. Dark: user mentions dark/cyber/terminal/neon/夜间/暗黑/deep/gaming/noir. Light (default): all other cases — SaaS, marketing, education, e-commerce, productivity, social. Never default to dark unless the content clearly calls for it.
|
||||||
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
|
- Detect the design type FIRST, then choose the appropriate structure and subtask count.
|
||||||
- Landing pages: include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
|
- Multi-section pages (type 1): include Navigation Bar as the FIRST subtask, followed by Hero, feature sections, CTA, footer, etc. (6-10 subtasks)
|
||||||
- App screens (login, settings, forms, etc.): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
|
- Single-task screens (type 2): do NOT include Navigation Bar, Hero, CTA, or footer. Only include the actual UI elements needed (1-5 subtasks).
|
||||||
- FORM INTEGRITY: Keep a form's core elements (inputs + submit button) in the same subtask. Splitting inputs into one subtask and the button into another causes duplicate buttons.
|
- FORM INTEGRITY: Keep a form's core elements (inputs + submit button) in the same subtask. Splitting inputs into one subtask and the button into another causes duplicate buttons.
|
||||||
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
|
- Combine related elements: "Hero with title + image + CTA" = ONE subtask, not three.
|
||||||
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
|
- Each subtask generates a meaningful section (~10-30 nodes). Only split if it would exceed 40 nodes.
|
||||||
- Choose a visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Output it in "styleGuide".
|
- REQUIRED: "styleGuide" must ALWAYS be included. Choose a distinctive visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Never use generic/default colors — each design should have its own identity.
|
||||||
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content — they have no CJK character support.
|
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content — they have no CJK character support.
|
||||||
- Root frame fill must use the styleGuide palette background color.
|
- Root frame fill must use the styleGuide palette background color.
|
||||||
- Root frame height: Mobile (width=375) → set height=812 (fixed viewport). Desktop (width=1200) → set height=0 (auto-expands as sections are generated).
|
- Root frame height: Mobile (width=375) → set height=812 (fixed viewport). Desktop (width=1200) → set height=0 (auto-expands as sections are generated).
|
||||||
|
|
@ -35,7 +42,7 @@ RULES:
|
||||||
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
|
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
|
||||||
- Regions tile to fill rootFrame. vertical = top-to-bottom.
|
- Regions tile to fill rootFrame. vertical = top-to-bottom.
|
||||||
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
|
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
|
||||||
- WIDTH SELECTION: App screens (login, signup, register, settings, profile, forms, modals, dialogs, onboarding) → ALWAYS use width=375, height=812 (mobile). Landing pages, websites, dashboards → use width=1200, height=0 (desktop). This is mandatory.
|
- WIDTH SELECTION: Single-task screens (type 2 above) → ALWAYS width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) → width=1200, height=0 (desktop). This is mandatory.
|
||||||
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
|
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile"), add "screen":"<name>" to each subtask to group sections that belong to the same page. Use a concise page name (e.g. "登录", "Profile"). Subtasks sharing the same "screen" are placed in one root frame. Single-screen requests don't need "screen". Example: [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
|
||||||
- NO explanation. NO markdown. JUST the JSON object.`
|
- NO explanation. NO markdown. JUST the JSON object.`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,11 @@ async function executeSubAgent(
|
||||||
request.context?.themes,
|
request.context?.themes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Inject design principles into the system prompt (selective, not all at once)
|
||||||
|
const systemPrompt = preparedPrompt.designPrinciples
|
||||||
|
? `${SUB_AGENT_PROMPT}\n\n${preparedPrompt.designPrinciples}`
|
||||||
|
: SUB_AGENT_PROMPT
|
||||||
|
|
||||||
let rawResponse = ''
|
let rawResponse = ''
|
||||||
const nodes: PenNode[] = []
|
const nodes: PenNode[] = []
|
||||||
let streamOffset = 0
|
let streamOffset = 0
|
||||||
|
|
@ -248,7 +253,7 @@ async function executeSubAgent(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const chunk of streamChat(
|
for await (const chunk of streamChat(
|
||||||
SUB_AGENT_PROMPT,
|
systemPrompt,
|
||||||
[{ role: 'user', content: userPrompt }],
|
[{ role: 'user', content: userPrompt }],
|
||||||
request.model,
|
request.model,
|
||||||
timeoutOptions,
|
timeoutOptions,
|
||||||
|
|
@ -465,6 +470,11 @@ CRITICAL LAYOUT CONSTRAINTS:
|
||||||
prompt += '\n\n' + varContext
|
prompt += '\n\n' + varContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject HTML reference from visual reference pipeline (if available)
|
||||||
|
if (subtask.htmlReference) {
|
||||||
|
prompt += `\n\n${subtask.htmlReference}\nUse this HTML structure as guidance for layout and content placement. Match the visual hierarchy and component structure.`
|
||||||
|
}
|
||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import { executeSubAgents } from './orchestrator-sub-agent'
|
||||||
import { emitProgress, buildFinalStepTags } from './orchestrator-progress'
|
import { emitProgress, buildFinalStepTags } from './orchestrator-progress'
|
||||||
import { assignAgentIdentities } from './agent-identity'
|
import { assignAgentIdentities } from './agent-identity'
|
||||||
import { addAgentFrame, clearAgentIndicators } from '@/canvas/agent-indicator'
|
import { addAgentFrame, clearAgentIndicators } from '@/canvas/agent-indicator'
|
||||||
|
import { enrichSubtasksWithHtmlReference } from './visual-ref-orchestrator'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
|
|
@ -87,6 +88,9 @@ export async function executeOrchestration(
|
||||||
st.parentFrameId = plan.rootFrame.id
|
st.parentFrameId = plan.rootFrame.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich subtasks with HTML references from visual reference pipeline (if active)
|
||||||
|
enrichSubtasksWithHtmlReference(plan.subtasks)
|
||||||
|
|
||||||
// Set canvas width hint for accurate text height estimation
|
// Set canvas width hint for accurate text height estimation
|
||||||
setGenerationCanvasWidth(plan.rootFrame.width)
|
setGenerationCanvasWidth(plan.rootFrame.width)
|
||||||
|
|
||||||
|
|
@ -499,7 +503,7 @@ function tryParsePlan(text: string): OrchestratorPlan | null {
|
||||||
|
|
||||||
const plan = obj as unknown as OrchestratorPlan
|
const plan = obj as unknown as OrchestratorPlan
|
||||||
|
|
||||||
// Extract optional styleGuide if present and well-formed
|
// Extract styleGuide — required for consistent visual output
|
||||||
if (obj.styleGuide && typeof obj.styleGuide === 'object') {
|
if (obj.styleGuide && typeof obj.styleGuide === 'object') {
|
||||||
const sg = obj.styleGuide as Record<string, unknown>
|
const sg = obj.styleGuide as Record<string, unknown>
|
||||||
if (sg.palette && typeof sg.palette === 'object' && sg.fonts && typeof sg.fonts === 'object') {
|
if (sg.palette && typeof sg.palette === 'object' && sg.fonts && typeof sg.fonts === 'object') {
|
||||||
|
|
@ -507,6 +511,24 @@ function tryParsePlan(text: string): OrchestratorPlan | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: always provide a style guide so sub-agents have consistent styling
|
||||||
|
if (!plan.styleGuide) {
|
||||||
|
const bg = (plan.rootFrame.fill as Array<{ color?: string }> | undefined)?.[0]?.color ?? '#F8FAFC'
|
||||||
|
plan.styleGuide = {
|
||||||
|
palette: {
|
||||||
|
background: bg,
|
||||||
|
surface: '#FFFFFF',
|
||||||
|
text: '#0F172A',
|
||||||
|
secondary: '#64748B',
|
||||||
|
accent: '#6366F1',
|
||||||
|
accent2: '#8B5CF6',
|
||||||
|
border: '#E2E8F0',
|
||||||
|
},
|
||||||
|
fonts: { heading: 'Space Grotesk', body: 'Inter' },
|
||||||
|
aesthetic: 'clean modern',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return plan
|
return plan
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue