mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.0.1 (#4)
* feat(canvas,panels): multi-selection drag, shift-key selection, and chat pipeline UI - Preserve multi-selection when clicking a selected object without Shift, enabling drag-move of the whole set - Set selectionKey to shiftKey so only Shift+click toggles multi-select - Refactor chat-message step rendering: extract step blocks with regex, add pipeline checklist progress UI, separate step display from markdown - Adjust AI chat panel selection count display and layout * feat(opencode): integrate OpenCode SDK for enhanced AI interactions - Add support for OpenCode as a new AI provider, allowing users to connect to a local OpenCode server. - Implement new API endpoints for generating and streaming chat responses via OpenCode. - Update UI components to include OpenCode in provider selection and display. - Introduce OpenCode logo and enhance the agent settings dialog to manage OpenCode connections. - Refactor existing chat and generation logic to accommodate provider-specific routing. - Include type definitions for OpenCode SDK to ensure type safety and improve developer experience. * feat(electron): integrate Electron framework for desktop application support - Add Electron configuration and main process setup for building a desktop application. - Implement IPC communication for file operations (open, save) between the renderer and main processes. - Create a preload script to expose Electron APIs to the renderer. - Update package.json to include Electron and related dependencies. - Enhance the build process with electron-builder for packaging the application. - Introduce a new electron-builder.yml configuration file for build settings. - Modify Vite configuration to support Electron-specific builds. - Update UI components to accommodate Electron's window management and drag regions. * fix(canvas): multi-selection drag reparenting and infinite recursion guard - Add re-entry guard to object:modified handler to prevent infinite recursion caused by discardActiveObject() firing object:modified via _finalizeCurrentTransform - Extend drag-into detection to non-layout root frames (rootFrameBounds) in addition to layout containers (layoutContainerBounds) - Add checkReparentIntoFrame() fallback for root-level objects dragged into frames, handling both layout and non-layout targets - Redirect _currentTransform to ActiveSelection in mouse:down so the whole group moves/scales/rotates together - Support same-container reorder in commitDragIntoMulti - Fix guide-utils getEdges() for center-origin ActiveSelection objects - Track selection descendants for visual drag-follow during multi-drag - Show position coordinates during multi-selection drag in dimension label * feat(store,canvas): add reusable component and instance system add makeReusable/detachComponent store methods, RefNode resolution in canvas sync with component/instance selection borders, virtual child ID handling in selection context, purple/instance-colored frame labels, and collision avoidance when duplicating nodes * feat(panels): add component/instance UI to layer and property panels layer panel shows diamond icon and purple/#9281f7 styling for components/instances with context menu actions; property panel resolves RefNode display, routes instance overrides, adds header with go-to and detach buttons, and supports inline name editing on click * feat(canvas): match hover outline to selection style for components and instances hover outlines now use purple solid for reusable components and #9281f7 dashed for instances, consistent with their selection border styling * fix(editor): save directly without dialog for previously opened files prioritize fileName check over File System Access API so opened files save via download without showing a file picker; also add Cmd+Alt+K shortcut for creating reusable components * refactor(canvas): remove dimension label overlay remove the blue dimension/position label that appeared on selection, drag, and scale interactions * feat(export): add export section for raster image export functionality - Introduce a new ExportSection component to facilitate exporting layers as raster images in various formats (PNG, JPEG, WEBP) with adjustable scale options. - Integrate the ExportSection into the PropertyPanel for easy access during design editing. - Enhance export utility functions to support exporting layers and managing descendant nodes. * feat(assets): add new icon files for application branding - Introduce new icon files in various formats (ICNS, ICO, PNG) for application branding. - Add logo image for the Electron interface to enhance visual identity. - Update canvas selection logic to allow programmatic selection from the layer panel. * feat(electron): enhance Electron app integration and CI/CD workflows - Add new commands for Electron development, compilation, and building processes in CLAUDE.md. - Update README.md to reflect the availability of the application as both a web and desktop app. - Introduce a FixedChecklist component in the AI chat panel for better user interaction with generated tasks. - Implement CI/CD workflows for automated testing and Electron builds in GitHub Actions. - Refactor design generator prompts to support element-by-element streaming for improved performance. * feat(ai): enhance chat functionality and orchestration capabilities - Introduce context optimization to manage chat history size and prevent unbounded growth. - Implement complexity assessment for design prompts to determine orchestration needs. - Add orchestrator functionality for parallel design generation, improving performance and responsiveness. - Update chat message parsing to include explicit status handling for orchestrator steps. - Refactor design generator to support orchestration and streamline context building. * refactor(ai): streamline orchestration and remove complexity classifier - Remove the complexity classifier as its functionality is no longer needed. - Update design generator to always route through the orchestrator, simplifying the logic. - Enhance error handling during orchestration to ensure fallback to direct generation is clear. - Introduce a new sub-agent prompt for improved output formatting and clarity in design generation. * feat(ai): refine orchestrator prompt and timeout settings - Update the ORCHESTRATOR_PROMPT to allow for more granular task division, specifying 4-15 atomic sections. - Enhance the JSON output structure by adding new subtasks for the hero section. - Adjust ORCHESTRATOR_TIMEOUTS to extend hard and no-text timeout values for improved orchestration performance. * feat(docs): update CLAUDE.md and README.md for new features and improvements - Expand the Fabric.js integration section in CLAUDE.md to include new files and functionalities. - Highlight new features such as double-click frame entry, advanced drag-and-drop capabilities, and per-layer export options in README.md. - Add context optimization and multi-provider support for AI in README.md. - Update the project structure descriptions for clarity and completeness. * refactor(config): update Vite configuration import for Vitest compatibility * chore(ci): update GitHub Actions workflow to trigger on main branch pushes * feat(uikit): introduce UIKit browser and component management features - Add a new ComponentBrowserPanel for browsing and managing UI components. - Implement ComponentBrowserGrid and ComponentBrowserCard for displaying components. - Integrate UIKit store for managing component kits, search queries, and active categories. - Enhance editor layout to support toggling the UIKit browser with keyboard shortcuts. - Include import/export functionality for UIKit components. * feat(types): add clipContent to ContainerProps Allow frames to explicitly clip overflowing children, essential for cards with cornerRadius + image children. * feat(canvas): wire letterSpacing, textGrowth and clipContent to Fabric.js - Convert letterSpacing (px) to Fabric charSpacing (1/1000 em) in factory and sync - Select IText vs Textbox based on textGrowth mode, restore canvas selection after text object recreation - Add multi-line text height estimation with CJK character support - Honor clipContent flag in clipping logic * feat(panels): split text Layout and Typography into separate sections - Add readOnly prop to NumberInput for non-editable dimension display - Extract text Layout section (dimensions, fill/hug, resizing toggles) into text-layout-section.tsx - Enhance Typography section with line height, letter spacing, vertical alignment controls - Reorder property panel: Size → Layout → Appearance → Fill → Stroke → Typography → Effects - Add hideWH prop to SizeSection to avoid duplicate W/H inputs * feat(panels): rewrite layout-section with full Flex Layout panel - 3x3 alignment grid with context-aware behavior per layout direction - Gap section with numeric/space-between/space-around radio modes - Multi-mode padding: single, 2-axis (V/H), 4-individual (T/R/B/L) with gear popover - Dimensions (W/H) and sizing checkboxes (fill/hug/clip) integrated into layout panel * feat(panels): improve layer panel with auto-collapse and scroll-to-selection - Collapse all layers by default, auto-collapse newly added nodes - Auto-expand ancestor layers when a child is selected on canvas - Scroll selected layer item into view after expansion * fix(panels): improve AI chat panel overflow and color picker styling - Fix chat panel overflow with proper flex layout and min-h-0 - Cap checklist height with scrollable overflow - Adjust color picker input sizing * feat(codegen): add textGrowth, textAlignVertical and clipContent to code generation - textGrowth: auto → whitespace-nowrap, fixed-width-height → overflow-hidden - textAlignVertical: middle/bottom → vertical-align CSS - clipContent: true → overflow-hidden on containers * feat(ai): integrate text typography properties into design prompts - Add textGrowth, lineHeight, letterSpacing, textAlignVertical, fontStyle to PEN_NODE_SCHEMA and sub-agent prompt - Add typography scale guidelines with lineHeight/letterSpacing defaults - Update examples with lineHeight, clipContent, fill_container usage - Add phone placeholder and clipContent rules * feat(ai): enhance streaming service with runtime config and thinking modes - Extract timeout constants to ai-runtime-config.ts - Add thinking mode control (adaptive/disabled/enabled) to stream options - Add StyleGuide interface for orchestrator visual consistency - Add ping timeout and first-text timeout options * feat(ai): improve design generator with typography defaults and phone placeholder - Set default lineHeight on text nodes (1.2 for headings, 1.5 for body) - Expand phone/mockup placeholder detection with shape-based fallback - Flatten nested phone-shaped frames in post-streaming tree fixes - Increase CJK character width estimation and button padding buffer * feat(ai): enhance orchestrator with style guide and prompt optimizer - Pass style guide from orchestrator plan to sub-agent prompts - Add orchestrator-prompt-optimizer for context-aware prompt tuning - Improve parallel sub-task generation and error recovery * feat(ai): add thinking mode and Codex provider support to server API - Support thinkingMode/effort params in chat and generate endpoints - Add Codex (OpenAI) provider streaming via codex-client utility - Forward thinking config to both Anthropic SDK and Agent SDK paths * feat(mcp): implement OpenPencil file format and MCP server integration - Introduce support for .op file format alongside .pen - Add MCP server functionality for document management and tool operations - Implement batch processing tools for design and variable management - Enhance save and open dialogs to accommodate new file format - Update dependencies and scripts for MCP server compilation and execution * feat(build): update electron build configuration and resource handling - Add mcp-server.cjs to extraResources in electron-builder.yml for production packaging - Modify package.json build script to include MCP server compilation - Enhance main.ts to set ELECTRON_RESOURCES_PATH for resource access - Update mcp-install.ts to resolve MCP server path from resources in production --------- Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
parent
e4670ce2e1
commit
3b00d5564d
101 changed files with 13656 additions and 876 deletions
90
.github/workflows/build-electron.yml
vendored
Normal file
90
.github/workflows/build-electron.yml
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
name: Build Electron
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: mac
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build web (electron target)
|
||||
run: bun --bun run build
|
||||
env:
|
||||
BUILD_TARGET: electron
|
||||
|
||||
- name: Compile electron
|
||||
run: bun run electron:compile
|
||||
|
||||
- name: Build Electron app
|
||||
run: npx electron-builder --config electron-builder.yml --${{ matrix.platform }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: electron-${{ matrix.platform }}
|
||||
path: |
|
||||
dist-electron/*.dmg
|
||||
dist-electron/*.zip
|
||||
dist-electron/*.exe
|
||||
dist-electron/*.AppImage
|
||||
dist-electron/*.deb
|
||||
retention-days: 30
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts/
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
55
.github/workflows/ci.yml
vendored
Normal file
55
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, v0.0.1]
|
||||
pull_request:
|
||||
branches: [main, v0.0.1]
|
||||
|
||||
jobs:
|
||||
lint-and-test:
|
||||
name: Lint & Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run tests
|
||||
run: bun --bun run test
|
||||
|
||||
build-web:
|
||||
name: Build Web
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: lint-and-test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun --bun run build
|
||||
|
||||
- name: Upload web build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: web-build
|
||||
path: .output/
|
||||
retention-days: 7
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -12,3 +12,5 @@ count.txt
|
|||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
electron-dist/
|
||||
dist-electron/
|
||||
|
|
|
|||
66
CLAUDE.md
66
CLAUDE.md
|
|
@ -11,16 +11,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||
- **Run a single test:** `bun --bun vitest run path/to/test.ts`
|
||||
- **Type check:** `npx tsc --noEmit`
|
||||
- **Install dependencies:** `bun install`
|
||||
- **Electron dev:** `bun run electron:dev` (starts Vite + Electron together)
|
||||
- **Electron compile:** `bun run electron:compile` (esbuild electron/ to electron-dist/)
|
||||
- **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package)
|
||||
|
||||
## Architecture
|
||||
|
||||
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**.
|
||||
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), TypeScript (strict mode).
|
||||
**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).
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
```text
|
||||
React Components (Toolbar, LayerPanel, PropertyPanel)
|
||||
│ Zustand hooks
|
||||
▼
|
||||
|
|
@ -43,7 +46,7 @@ React Components (Toolbar, LayerPanel, PropertyPanel)
|
|||
|
||||
### Design Variables Architecture
|
||||
|
||||
```
|
||||
```text
|
||||
PenDocument (source of truth)
|
||||
├── variables: Record<string, VariableDefinition> ($color-1, $spacing-md, ...)
|
||||
├── themes: Record<string, string[]> ({Theme-1: ["Default","Dark"]})
|
||||
|
|
@ -65,8 +68,9 @@ PenDocument (source of truth)
|
|||
|
||||
### Key Modules
|
||||
|
||||
- **`src/canvas/`** — Fabric.js integration (16 files):
|
||||
- **`src/canvas/`** — Fabric.js integration (25 files):
|
||||
- `fabric-canvas.tsx` — Canvas component initialization
|
||||
- `use-fabric-canvas.ts` — Canvas initialization hook
|
||||
- `canvas-object-factory.ts` — Creates Fabric objects from PenNodes (rect, ellipse, line, polygon, path, text, image, frame, group)
|
||||
- `canvas-object-sync.ts` — Syncs individual object properties between Fabric and store
|
||||
- `canvas-sync-lock.ts` — Prevents circular sync loops
|
||||
|
|
@ -76,12 +80,20 @@ PenDocument (source of truth)
|
|||
- `use-canvas-sync.ts` — Bidirectional PenDocument ↔ Fabric.js sync, node flattening with parent offsets, variable resolution via `resolveNodeForCanvas()`
|
||||
- `use-canvas-viewport.ts` — Wheel zoom, space+drag panning, tool cursor switching, selection toggling per tool
|
||||
- `use-canvas-selection.ts` — Selection sync between Fabric objects and canvas-store
|
||||
- `use-canvas-hover.ts` — Hover state management for objects
|
||||
- `use-canvas-guides.ts` — Smart alignment guides with snapping
|
||||
- `guide-utils.ts` — Guide calculation and rendering
|
||||
- `pen-tool.ts` — Bezier pen tool: anchor points, control handles, path closure, preview rendering
|
||||
- `parent-child-transform.ts` — Propagates parent transforms (move/scale/rotate) to children proportionally
|
||||
- `use-dimension-label.ts` — Shows size/position labels during object manipulation
|
||||
- `use-frame-labels.ts` — Renders frame names and boundaries on canvas
|
||||
- `use-entered-frame-overlay.ts` — Visual overlay when entering a frame for editing
|
||||
- `use-layout-indicator.ts` — Layout indicator rendering during drag operations
|
||||
- `insertion-indicator.ts` — Insertion point indicator for layout drop targets
|
||||
- `drag-into-layout.ts` — Drag-and-drop into auto-layout frames with insertion detection
|
||||
- `drag-reparent.ts` — Reparenting nodes during drag operations
|
||||
- `layout-reorder.ts` — Reorder children within layout frames during drag
|
||||
- `selection-context.ts` — Selection context management for multi-select operations
|
||||
- **`src/variables/`** — Design variables system (2 files):
|
||||
- `resolve-variables.ts` — Core resolution utilities: `resolveVariableRef`, `resolveNodeForCanvas`, `getDefaultTheme`, `isVariableRef`; resolves `$variable` references to concrete values for canvas rendering with circular reference guards
|
||||
- `replace-refs.ts` — `replaceVariableRefsInTree`: recursively walk node tree to replace/resolve `$refs` when renaming or deleting variables (covers opacity, gap, padding, fills, strokes, effects, text)
|
||||
|
|
@ -91,14 +103,16 @@ PenDocument (source of truth)
|
|||
- `history-store.ts` — Undo/redo (max 300 states), batch mode for grouped operations
|
||||
- `ai-store.ts` — Chat messages, streaming state, generated code, model selection
|
||||
- `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI), MCP CLI integrations, localStorage persistence
|
||||
- **`src/types/`** — Type system:
|
||||
- **`src/types/`** — Type system (7 files):
|
||||
- `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps; `PenDocument.variables` and `PenDocument.themes`
|
||||
- `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState
|
||||
- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur)
|
||||
- `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState, CanvasInteraction
|
||||
- `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur), BlendMode, StyledTextSegment
|
||||
- `variables.ts` — `VariableDefinition` (type + value), `ThemedValue` (value per theme), `VariableValue`
|
||||
- `agent-settings.ts` — AI provider config types
|
||||
- `agent-settings.ts` — AI provider config types (`AIProviderType`, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`)
|
||||
- `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations)
|
||||
- `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk
|
||||
- **`src/components/editor/`** — Editor UI (6 files): editor-layout, toolbar (with variables panel toggle), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar, status-bar
|
||||
- **`src/components/panels/`** — Panels (17 files):
|
||||
- **`src/components/panels/`** — Panels (18 files):
|
||||
- `layer-panel.tsx` / `layer-item.tsx` / `layer-context-menu.tsx` — Tree view with drag-and-drop reordering and drop-into-children (above/below/inside), visibility/lock toggles, context menu, rename
|
||||
- `property-panel.tsx` — Unified property panel
|
||||
- `fill-section.tsx` — Solid + gradient fill, variable picker integration for color binding
|
||||
|
|
@ -107,6 +121,7 @@ PenDocument (source of truth)
|
|||
- `size-section.tsx` — Position, size, rotation
|
||||
- `text-section.tsx` — Font, size, weight, spacing, alignment
|
||||
- `effects-section.tsx` — Shadow and blur
|
||||
- `export-section.tsx` — Per-layer export to PNG/SVG with scale options (1x/2x/3x)
|
||||
- `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align; variable picker for gap/padding binding
|
||||
- `appearance-section.tsx` — Opacity, visibility, lock, flip; variable picker for opacity binding
|
||||
- `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design
|
||||
|
|
@ -114,14 +129,25 @@ PenDocument (source of truth)
|
|||
- `variables-panel.tsx` — Design variables management: theme axes as tabs, variant columns, resizable floating panel, add/rename/delete themes and variants
|
||||
- `variable-row.tsx` — Individual variable row: type icon, editable name, per-theme-variant value cells (color picker, number input, text input), context menu
|
||||
- **`src/components/shared/`** — Reusable UI (9 files): ColorPicker, NumberInput, DropdownSelect, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker
|
||||
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo
|
||||
- **`src/components/icons/`** — Provider logos: ClaudeLogo, OpenAILogo, OpenCodeLogo
|
||||
- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip
|
||||
- **`src/services/ai/`** — AI chat service, design prompts, design-to-node generation, AI types
|
||||
- **`src/services/ai/`** — AI services (8 files):
|
||||
- `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection
|
||||
- `ai-prompts.ts` — System prompts for design generation, context building
|
||||
- `ai-types.ts` — `ChatMessage`, `AIDesignRequest`, streaming response types
|
||||
- `design-generator.ts` — Converts AI JSONL to PenNodes, applies variables, animation coordination
|
||||
- `design-animation.ts` — Fade-in animation coordination for generated design nodes
|
||||
- `orchestrator.ts` — Parallel design generation: decomposes requests into spatial sub-tasks, JSONL streaming, fallback to single-call
|
||||
- `orchestrator-prompts.ts` — Ultra-lightweight orchestrator prompt for spatial decomposition
|
||||
- `context-optimizer.ts` — Chat history trimming, sliding window to prevent unbounded context growth
|
||||
- **`src/services/codegen/`** — React+Tailwind and HTML+CSS code generators (output `var(--name)` for `$variable` refs), CSS variables generator
|
||||
- **`src/hooks/`** — `use-keyboard-shortcuts` (global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order)
|
||||
- **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging)
|
||||
- **`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
|
||||
- **`server/api/ai/`** — Nitro server API: `chat.ts` (streaming SSE with thinking state), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI connection), `models.ts` (model definitions). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
|
||||
- **`server/api/ai/`** — Nitro server API: `chat.ts` (streaming SSE with thinking state), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode connection), `models.ts` (model definitions). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers
|
||||
- **`server/utils/`** — Server utilities:
|
||||
- `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`)
|
||||
- `opencode-client.ts` — Shared OpenCode client manager, reuses server on port 4096 with random port fallback
|
||||
|
||||
### Fabric.js v7 Gotchas
|
||||
|
||||
|
|
@ -155,6 +181,20 @@ File-based routing via TanStack Router. Routes in `src/routes/`, auto-generated
|
|||
|
||||
Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`src/components/ui/`). Icons from `lucide-react`. shadcn/ui config in `components.json`.
|
||||
|
||||
### Electron Desktop App
|
||||
|
||||
- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, macOS traffic-light padding (auto-hidden in fullscreen)
|
||||
- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC
|
||||
- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb)
|
||||
- **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron
|
||||
- Build flow: `BUILD_TARGET=electron bun run build` → `bun run electron:compile` → `npx electron-builder`
|
||||
- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor`
|
||||
|
||||
### CI / CD
|
||||
|
||||
- **`.github/workflows/ci.yml`** — Push/PR: type check (`tsc --noEmit`), tests (`vitest`), web build
|
||||
- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for macOS, Windows, Linux in parallel, creates draft GitHub Release with all artifacts
|
||||
|
||||
## Code Style
|
||||
|
||||
- 单个文件不要超过 800 行。超出时应拆分为更小的模块。
|
||||
|
|
|
|||
106
README.md
106
README.md
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
Open-source vector design tool with a Design-as-Code philosophy. An alternative to [Pencil.dev](https://pencil.dev).
|
||||
|
||||
Available as a **web app** and **desktop app** (macOS / Windows / Linux via Electron).
|
||||
|
||||
## Features
|
||||
|
||||
### Canvas
|
||||
|
|
@ -10,6 +12,8 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
- Smart alignment guides with edge, center, and distance snapping
|
||||
- Dimension labels during object manipulation
|
||||
- Frame labels and boundary visualization
|
||||
- Double-click to enter frames with visual overlay
|
||||
- Advanced drag-and-drop: drag into auto-layout frames with insertion indicators, reparenting, reorder within layout
|
||||
|
||||
### Drawing Tools
|
||||
|
||||
|
|
@ -30,6 +34,7 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
- Effects: shadow and blur
|
||||
- Auto-layout: direction, gap, padding, justify-content, align-items
|
||||
- Variable binding: bind any property to a design variable via variable picker
|
||||
- Per-layer export: export individual layers to PNG/SVG with scale options (1x/2x/3x)
|
||||
|
||||
### Design Variables & Tokens
|
||||
|
||||
|
|
@ -83,10 +88,12 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
|
||||
- Built-in AI chat panel (Cmd+J)
|
||||
- AI-powered design generation from text prompts
|
||||
- Orchestrator-based parallel design generation: decomposes requests into spatial sub-tasks for faster output
|
||||
- Design block preview with "Apply Design" action
|
||||
- Streaming responses with thinking state
|
||||
- Streaming responses with thinking state and JSONL real-time canvas insertion
|
||||
- Context optimizer: sliding window history trimming to prevent unbounded context growth
|
||||
- Dual provider: Anthropic API or local Claude Code (OAuth)
|
||||
- Multi-provider settings: Claude Code, Codex CLI
|
||||
- Multi-provider settings: Claude Code, Codex CLI, OpenCode
|
||||
|
||||
### Editor UI
|
||||
|
||||
|
|
@ -132,12 +139,16 @@ Open-source vector design tool with a Design-as-Code philosophy. An alternative
|
|||
- **Styling:** [Tailwind CSS](https://tailwindcss.com/) v4
|
||||
- **Icons:** [Lucide React](https://lucide.dev/)
|
||||
- **Server:** [Nitro](https://nitro.build/) (API routes)
|
||||
- **AI:** [Anthropic SDK](https://docs.anthropic.com/) + [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk)
|
||||
- **Desktop:** [Electron](https://www.electronjs.org/) 35 + [electron-builder](https://www.electron.build/)
|
||||
- **AI:** [Anthropic SDK](https://docs.anthropic.com/) + [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk) + [OpenCode SDK](https://github.com/opencode-ai/sdk)
|
||||
- **Runtime:** [Bun](https://bun.sh/)
|
||||
- **Build:** [Vite](https://vite.dev/) 7
|
||||
- **CI/CD:** GitHub Actions
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Web (Development)
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun --bun run dev
|
||||
|
|
@ -145,65 +156,94 @@ bun --bun run dev
|
|||
|
||||
Open http://localhost:3000 and click "New Design" to enter the editor.
|
||||
|
||||
### Electron (Desktop)
|
||||
|
||||
```bash
|
||||
# Development: starts Vite dev server + Electron
|
||||
bun run electron:dev
|
||||
|
||||
# Production build (current platform)
|
||||
bun run electron:build
|
||||
```
|
||||
|
||||
### AI Configuration
|
||||
|
||||
The AI assistant works in two modes:
|
||||
The AI assistant works in multiple modes:
|
||||
|
||||
- **Anthropic API**: Set `ANTHROPIC_API_KEY` in `.env`
|
||||
- **Local Claude Code**: No config needed — uses Claude Agent SDK with OAuth login as fallback
|
||||
- **OpenCode**: Connect via OpenCode SDK for additional model support
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `bun --bun run dev` | Start dev server on port 3000 |
|
||||
| `bun --bun run build` | Production build |
|
||||
| `bun --bun run dev` | Start web dev server on port 3000 |
|
||||
| `bun --bun run build` | Production web build |
|
||||
| `bun --bun run preview` | Preview production build |
|
||||
| `bun --bun run test` | Run tests (Vitest) |
|
||||
| `npx tsc --noEmit` | Type check |
|
||||
| `bun run electron:dev` | Start Vite + Electron for desktop dev |
|
||||
| `bun run electron:compile` | Compile electron/ with esbuild |
|
||||
| `bun run electron:build` | Full Electron package (web build + compile + electron-builder) |
|
||||
|
||||
## CI / CD
|
||||
|
||||
### CI (`ci.yml`)
|
||||
|
||||
Runs on every push and PR to `main` / `v0.0.1`:
|
||||
|
||||
1. **Lint & Test** — type check (`tsc --noEmit`) + unit tests (`vitest`)
|
||||
2. **Build Web** — production web build, uploads `.output/` as artifact
|
||||
|
||||
### Build Electron (`build-electron.yml`)
|
||||
|
||||
Triggered by version tags (`v*`) or manual dispatch:
|
||||
|
||||
1. **Build** — parallel matrix across macOS, Windows, Linux
|
||||
- macOS: `.dmg` + `.zip`
|
||||
- Windows: `.exe` (NSIS installer + portable)
|
||||
- Linux: `.AppImage` + `.deb`
|
||||
2. **Release** — creates a draft GitHub Release with all platform artifacts
|
||||
|
||||
To create a release:
|
||||
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
```text
|
||||
src/
|
||||
canvas/ # Fabric.js canvas engine (16 files)
|
||||
fabric-canvas.tsx Canvas component initialization
|
||||
canvas-object-factory Creates Fabric objects from PenNodes
|
||||
canvas-object-sync Syncs object properties Fabric ↔ store
|
||||
canvas-sync-lock Prevents circular sync loops
|
||||
canvas-controls Custom rotation controls and cursors
|
||||
canvas-constants Default colors, zoom limits
|
||||
use-canvas-events Drawing events, tool management
|
||||
use-canvas-sync Bidirectional PenDocument ↔ Fabric sync + variable resolution
|
||||
use-canvas-viewport Zoom, pan, tool cursor switching
|
||||
use-canvas-selection Selection sync Fabric ↔ store
|
||||
use-canvas-guides Smart alignment guides
|
||||
guide-utils Guide calculation and rendering
|
||||
pen-tool Bezier pen tool with anchors/handles
|
||||
parent-child-transform Parent transform propagation to children
|
||||
use-dimension-label Size/position labels during manipulation
|
||||
use-frame-labels Frame name/boundary rendering
|
||||
variables/ # Design variables/tokens system
|
||||
resolve-variables Core $variable resolution for canvas rendering
|
||||
replace-refs Replace/resolve $refs on rename/delete
|
||||
canvas/ # Fabric.js canvas engine (25 files: sync, events, guides, drag-drop, pen tool, etc.)
|
||||
variables/ # Design variables/tokens system (resolve, replace refs)
|
||||
components/
|
||||
editor/ # Editor layout, toolbar, tool buttons, status bar
|
||||
panels/ # Layer panel, property panel (17 files), AI chat, code panel,
|
||||
# variables panel, variable row
|
||||
editor/ # Editor layout, toolbar, tool buttons, top bar, status bar
|
||||
panels/ # Layer panel, property panel, AI chat, code panel, variables panel, export section
|
||||
shared/ # ColorPicker, NumberInput, VariablePicker, ExportDialog, etc.
|
||||
icons/ # Provider logos (Claude, OpenAI)
|
||||
icons/ # Provider logos (Claude, OpenAI, OpenCode)
|
||||
ui/ # shadcn/ui primitives (Button, Select, Slider, Switch, etc.)
|
||||
hooks/ # Keyboard shortcuts
|
||||
lib/ # Utility functions (cn class merging)
|
||||
services/
|
||||
ai/ # AI chat service, prompts, design generation
|
||||
ai/ # AI chat, orchestrator, context optimizer, design generation, prompts
|
||||
codegen/ # React+Tailwind, HTML+CSS, and CSS variables generators
|
||||
stores/ # Zustand stores (canvas, document, history, AI, agent-settings)
|
||||
types/ # PenDocument/PenNode types, style types, variables, agent settings
|
||||
types/ # PenDocument/PenNode types, style types, variables, agent settings, Electron IPC
|
||||
utils/ # File operations, export, node clone, SVG parser, syntax highlight
|
||||
routes/ # TanStack Router pages (/, /editor)
|
||||
electron/
|
||||
main.ts # Electron main process (window, Nitro server, IPC, fullscreen handling)
|
||||
preload.ts # Context bridge for renderer ↔ main IPC
|
||||
server/
|
||||
api/ai/ # Nitro API: streaming chat, generation, agent connection, models
|
||||
utils/ # Server utilities: Claude CLI resolver, OpenCode client manager
|
||||
.github/
|
||||
workflows/
|
||||
ci.yml # CI: type check, test, web build
|
||||
build-electron.yml # Electron build for macOS/Windows/Linux + GitHub Release
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
|
|
|||
BIN
build/icon.icns
Normal file
BIN
build/icon.icns
Normal file
Binary file not shown.
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
BIN
build/icon.png
Normal file
BIN
build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 288 KiB |
53
electron-builder.yml
Normal file
53
electron-builder.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
appId: dev.openpencil.app
|
||||
productName: OpenPencil
|
||||
copyright: Copyright (c) 2024-2026 OpenPencil contributors
|
||||
|
||||
directories:
|
||||
output: dist-electron
|
||||
buildResources: build
|
||||
|
||||
files:
|
||||
- electron-dist/**/*
|
||||
- "!node_modules"
|
||||
|
||||
extraResources:
|
||||
- from: .output/server
|
||||
to: server
|
||||
- from: .output/public
|
||||
to: public
|
||||
- from: dist/mcp-server.cjs
|
||||
to: mcp-server.cjs
|
||||
|
||||
mac:
|
||||
category: public.app-category.graphics-design
|
||||
icon: build/icon.icns
|
||||
target:
|
||||
- dmg
|
||||
- zip
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
|
||||
dmg:
|
||||
title: "${productName} ${version}"
|
||||
|
||||
win:
|
||||
icon: build/icon.ico
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
|
||||
linux:
|
||||
icon: build/icon.png
|
||||
category: Graphics
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
asar: true
|
||||
|
||||
publish: null
|
||||
BIN
electron/logo.png
Normal file
BIN
electron/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
272
electron/main.ts
Normal file
272
electron/main.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain,
|
||||
dialog,
|
||||
type BrowserWindowConstructorOptions,
|
||||
} from 'electron'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let nitroProcess: ChildProcess | null = null
|
||||
let serverPort = 0
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix PATH for macOS GUI apps (Finder doesn't inherit shell PATH)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fixPath(): void {
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
||||
|
||||
try {
|
||||
const shell = process.env.SHELL || '/bin/zsh'
|
||||
const shellPath = execSync(`${shell} -ilc 'echo -n "$PATH"'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim()
|
||||
if (shellPath) {
|
||||
const current = process.env.PATH || ''
|
||||
process.env.PATH = [...new Set([...shellPath.split(':'), ...current.split(':')])]
|
||||
.filter(Boolean)
|
||||
.join(':')
|
||||
}
|
||||
} catch {
|
||||
// Packaged app may not have a login shell — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getFreePorts(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr && typeof addr === 'object') {
|
||||
const { port } = addr
|
||||
server.close(() => resolve(port))
|
||||
} else {
|
||||
reject(new Error('Failed to get free port'))
|
||||
}
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function getServerEntry(): string {
|
||||
if (isDev) {
|
||||
// In dev, the Nitro output lives at .output/server/index.mjs
|
||||
return join(app.getAppPath(), '.output', 'server', 'index.mjs')
|
||||
}
|
||||
// In production, extraResources copies .output into the resources folder
|
||||
return join(process.resourcesPath, 'server', 'index.mjs')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nitro server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function startNitroServer(): Promise<number> {
|
||||
const port = await getFreePorts()
|
||||
const entry = getServerEntry()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = fork(entry, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOST: '127.0.0.1',
|
||||
PORT: String(port),
|
||||
NITRO_HOST: '127.0.0.1',
|
||||
NITRO_PORT: String(port),
|
||||
ELECTRON_RESOURCES_PATH: process.resourcesPath,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
nitroProcess = child
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const msg = data.toString()
|
||||
console.log('[nitro]', msg)
|
||||
// Resolve once Nitro reports it's listening
|
||||
if (msg.includes('Listening') || msg.includes('ready')) {
|
||||
resolve(port)
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
console.error('[nitro:err]', data.toString())
|
||||
})
|
||||
|
||||
child.on('error', reject)
|
||||
child.on('exit', (code) => {
|
||||
if (code !== 0 && code !== null) {
|
||||
console.error(`Nitro exited with code ${code}`)
|
||||
}
|
||||
nitroProcess = null
|
||||
})
|
||||
|
||||
// Fallback: if no stdout "ready" message comes, wait then resolve anyway
|
||||
setTimeout(() => resolve(port), 3000)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createWindow(): void {
|
||||
const windowOptions: BrowserWindowConstructorOptions = {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 600,
|
||||
title: 'OpenPencil',
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
windowOptions.trafficLightPosition = { x: 16, y: 11 }
|
||||
}
|
||||
|
||||
// Start hidden to avoid visual flash before CSS injection
|
||||
windowOptions.show = false
|
||||
|
||||
mainWindow = new BrowserWindow(windowOptions)
|
||||
|
||||
const url = isDev
|
||||
? 'http://localhost:3000/editor'
|
||||
: `http://127.0.0.1:${serverPort}/editor`
|
||||
|
||||
// Inject traffic-light padding CSS then show window (no flash)
|
||||
mainWindow.webContents.on('did-finish-load', async () => {
|
||||
if (!mainWindow) return
|
||||
if (process.platform === 'darwin') {
|
||||
await mainWindow.webContents.insertCSS(
|
||||
'.electron-traffic-light-pad { margin-left: 74px; }' +
|
||||
'.electron-fullscreen .electron-traffic-light-pad { margin-left: 0; }',
|
||||
)
|
||||
}
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
// Toggle fullscreen class to remove traffic-light padding in fullscreen
|
||||
if (process.platform === 'darwin') {
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
mainWindow?.webContents.executeJavaScript(
|
||||
'document.body.classList.add("electron-fullscreen")',
|
||||
)
|
||||
})
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
mainWindow?.webContents.executeJavaScript(
|
||||
'document.body.classList.remove("electron-fullscreen")',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
mainWindow.loadURL(url)
|
||||
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC: native file dialogs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupIPC(): void {
|
||||
ipcMain.handle('dialog:openFile', async () => {
|
||||
if (!mainWindow) return null
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: 'Open .op file',
|
||||
filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }],
|
||||
properties: ['openFile'],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const filePath = result.filePaths[0]
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
return { filePath, content }
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'dialog:saveFile',
|
||||
async (_event, payload: { content: string; defaultPath?: string }) => {
|
||||
if (!mainWindow) return null
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: 'Save .op file',
|
||||
defaultPath: payload.defaultPath,
|
||||
filters: [{ name: 'OpenPencil Files', extensions: ['op'] }],
|
||||
})
|
||||
if (result.canceled || !result.filePath) return null
|
||||
await writeFile(result.filePath, payload.content, 'utf-8')
|
||||
return result.filePath
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'dialog:saveToPath',
|
||||
async (_event, payload: { filePath: string; content: string }) => {
|
||||
await writeFile(payload.filePath, payload.content, 'utf-8')
|
||||
return payload.filePath
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.on('ready', async () => {
|
||||
fixPath()
|
||||
setupIPC()
|
||||
|
||||
if (!isDev) {
|
||||
try {
|
||||
serverPort = await startNitroServer()
|
||||
console.log(`Nitro server started on port ${serverPort}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to start Nitro server:', err)
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
createWindow()
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
if (nitroProcess) {
|
||||
nitroProcess.kill()
|
||||
nitroProcess = null
|
||||
}
|
||||
})
|
||||
25
electron/preload.ts
Normal file
25
electron/preload.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
export interface ElectronAPI {
|
||||
isElectron: true
|
||||
openFile: () => Promise<{ filePath: string; content: string } | null>
|
||||
saveFile: (
|
||||
content: string,
|
||||
defaultPath?: string,
|
||||
) => Promise<string | null>
|
||||
saveToPath: (filePath: string, content: string) => Promise<string>
|
||||
}
|
||||
|
||||
const api: ElectronAPI = {
|
||||
isElectron: true,
|
||||
|
||||
openFile: () => ipcRenderer.invoke('dialog:openFile'),
|
||||
|
||||
saveFile: (content: string, defaultPath?: string) =>
|
||||
ipcRenderer.invoke('dialog:saveFile', { content, defaultPath }),
|
||||
|
||||
saveToPath: (filePath: string, content: string) =>
|
||||
ipcRenderer.invoke('dialog:saveToPath', { filePath, content }),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', api)
|
||||
15
electron/tsconfig.json
Normal file
15
electron/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "../electron-dist",
|
||||
"rootDir": ".",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
19
package.json
19
package.json
|
|
@ -1,16 +1,30 @@
|
|||
{
|
||||
"name": "openpencil",
|
||||
"version": "0.0.1",
|
||||
"description": "Open-source vector design tool with Design-as-Code philosophy",
|
||||
"author": "ZSeven-W",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "electron-dist/main.cjs",
|
||||
"bin": {
|
||||
"openpencil-mcp": "dist/mcp-server.cjs"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun --bun vite dev --port 3000",
|
||||
"build": "bun --bun vite build",
|
||||
"preview": "bun --bun vite preview",
|
||||
"test": "bun --bun vitest run"
|
||||
"test": "bun --bun vitest run --passWithNoTests",
|
||||
"mcp:compile": "esbuild src/mcp/server.ts --bundle --platform=node --target=node20 --outfile=dist/mcp-server.cjs --format=cjs --sourcemap --alias:@=src",
|
||||
"mcp:dev": "bun run src/mcp/server.ts",
|
||||
"electron:dev": "bun run scripts/electron-dev.ts",
|
||||
"electron:compile": "esbuild electron/main.ts electron/preload.ts --bundle --platform=node --target=node20 --outdir=electron-dist --external:electron --format=cjs --out-extension:.js=.cjs --sourcemap",
|
||||
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
|
||||
"@anthropic-ai/sdk": "^0.77.0",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
|
|
@ -45,6 +59,9 @@
|
|||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"electron": "^35.0.0",
|
||||
"electron-builder": "^26.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
|
|
|
|||
110
scripts/electron-dev.ts
Normal file
110
scripts/electron-dev.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Electron development workflow orchestrator.
|
||||
*
|
||||
* 1. Start Vite dev server (bun run dev)
|
||||
* 2. Wait for it to be ready on port 3000
|
||||
* 3. Compile electron/ with esbuild
|
||||
* 4. Launch Electron pointing at the dev server
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { build } from 'esbuild'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function waitForServer(
|
||||
url: string,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (res.ok || res.status < 500) return
|
||||
} catch {
|
||||
// server not ready yet
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
}
|
||||
throw new Error(`Timeout waiting for ${url}`)
|
||||
}
|
||||
|
||||
async function compileElectron(): Promise<void> {
|
||||
const common: Parameters<typeof build>[0] = {
|
||||
platform: 'node',
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
external: ['electron'],
|
||||
target: 'node20',
|
||||
outdir: join(ROOT, 'electron-dist'),
|
||||
outExtension: { '.js': '.cjs' },
|
||||
format: 'cjs' as const,
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
...common,
|
||||
entryPoints: [join(ROOT, 'electron', 'main.ts')],
|
||||
}),
|
||||
build({
|
||||
...common,
|
||||
entryPoints: [join(ROOT, 'electron', 'preload.ts')],
|
||||
}),
|
||||
])
|
||||
|
||||
console.log('[electron-dev] Electron files compiled')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// 1. Start Vite dev server
|
||||
console.log('[electron-dev] Starting Vite dev server...')
|
||||
const vite = spawn('bun', ['--bun', 'run', 'dev'], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
})
|
||||
|
||||
// Ensure cleanup on exit
|
||||
const cleanup = () => {
|
||||
vite.kill()
|
||||
process.exit()
|
||||
}
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
|
||||
// 2. Wait for Vite to be ready
|
||||
console.log('[electron-dev] Waiting for Vite on port 3000...')
|
||||
await waitForServer('http://localhost:3000')
|
||||
console.log('[electron-dev] Vite is ready')
|
||||
|
||||
// 3. Compile Electron files
|
||||
await compileElectron()
|
||||
|
||||
// 4. Launch Electron
|
||||
console.log('[electron-dev] Starting Electron...')
|
||||
const electronBin = join(ROOT, 'node_modules', '.bin', 'electron')
|
||||
const electron = spawn(electronBin, [join(ROOT, 'electron-dist', 'main.cjs')], {
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env },
|
||||
}) as ChildProcess
|
||||
|
||||
electron.on('exit', () => {
|
||||
vite.kill()
|
||||
process.exit()
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import { runCodexExec } from '../../utils/codex-client'
|
||||
|
||||
interface ChatBody {
|
||||
system: string
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
model?: string
|
||||
provider?: string
|
||||
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
|
||||
thinkingBudgetTokens?: number
|
||||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -25,6 +31,15 @@ export default defineEventHandler(async (event) => {
|
|||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
// Explicit provider routing
|
||||
if (body.provider === 'opencode') {
|
||||
return streamViaOpenCode(body, body.model)
|
||||
}
|
||||
if (body.provider === 'openai') {
|
||||
return streamViaCodex(body, body.model)
|
||||
}
|
||||
|
||||
// Default: existing behavior (backward-compatible)
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (apiKey) {
|
||||
try {
|
||||
|
|
@ -39,6 +54,29 @@ export default defineEventHandler(async (event) => {
|
|||
// Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT
|
||||
const KEEPALIVE_INTERVAL_MS = 15_000
|
||||
|
||||
function getAnthropicThinkingConfig(body: ChatBody):
|
||||
| { type: 'adaptive' | 'disabled' }
|
||||
| { type: 'enabled'; budget_tokens: number }
|
||||
| undefined {
|
||||
if (!body.thinkingMode) return undefined
|
||||
if (body.thinkingMode === 'enabled') {
|
||||
const budget = Math.max(1024, body.thinkingBudgetTokens ?? 1024)
|
||||
return { type: 'enabled', budget_tokens: budget }
|
||||
}
|
||||
return { type: body.thinkingMode }
|
||||
}
|
||||
|
||||
function getAgentThinkingConfig(body: ChatBody):
|
||||
| { type: 'adaptive' | 'disabled' }
|
||||
| { type: 'enabled'; budgetTokens?: number }
|
||||
| undefined {
|
||||
if (!body.thinkingMode) return undefined
|
||||
if (body.thinkingMode === 'enabled') {
|
||||
return { type: 'enabled', budgetTokens: body.thinkingBudgetTokens }
|
||||
}
|
||||
return { type: body.thinkingMode }
|
||||
}
|
||||
|
||||
/** Stream via Anthropic SDK (when API key is available) */
|
||||
async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: string) {
|
||||
const { default: Anthropic } = await import('@anthropic-ai/sdk')
|
||||
|
|
@ -54,11 +92,14 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
|
|||
} catch { /* stream already closed */ }
|
||||
}, KEEPALIVE_INTERVAL_MS)
|
||||
try {
|
||||
const thinking = getAnthropicThinkingConfig(body)
|
||||
const messageStream = client.messages.stream({
|
||||
model: model || 'claude-sonnet-4-5-20250929',
|
||||
max_tokens: 16384,
|
||||
system: body.system,
|
||||
messages: body.messages,
|
||||
...(body.effort ? { effort: body.effort } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
})
|
||||
|
||||
for await (const ev of messageStream) {
|
||||
|
|
@ -68,7 +109,7 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str
|
|||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
clearInterval(pingTimer)
|
||||
// Keep pings alive during thinking — only stop on text output
|
||||
const data = JSON.stringify({ type: 'thinking', content: ev.delta.thinking })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
|
|
@ -110,12 +151,15 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
|
||||
// Build prompt from the last user message
|
||||
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
||||
const prompt = lastUserMsg?.content ?? ''
|
||||
let prompt = lastUserMsg?.content ?? ''
|
||||
|
||||
// Remove CLAUDECODE env to allow running from within a CC terminal
|
||||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
const thinking = getAgentThinkingConfig(body)
|
||||
|
||||
const q = query({
|
||||
prompt,
|
||||
options: {
|
||||
|
|
@ -124,9 +168,13 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
maxTurns: 1,
|
||||
includePartialMessages: true,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
...(body.effort ? { effort: body.effort } : {}),
|
||||
...(thinking ? { thinking } : {}),
|
||||
env,
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -139,7 +187,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
const data = JSON.stringify({ type: 'text', content: ev.delta.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
} else if (ev.delta.type === 'thinking_delta') {
|
||||
clearInterval(pingTimer)
|
||||
// Keep pings alive during thinking — only stop on text output
|
||||
const data = JSON.stringify({ type: 'thinking', content: (ev.delta as any).thinking })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
|
|
@ -172,3 +220,197 @@ function streamViaAgentSDK(body: ChatBody, model?: string) {
|
|||
|
||||
return new Response(stream)
|
||||
}
|
||||
|
||||
/** Parse an OpenCode model string ("providerID/modelID") into its parts */
|
||||
function parseOpenCodeModel(model?: string): { providerID: string; modelID: string } | undefined {
|
||||
if (!model || !model.includes('/')) return undefined
|
||||
const idx = model.indexOf('/')
|
||||
return { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
|
||||
}
|
||||
|
||||
function mapOpenCodeEffort(
|
||||
effort?: 'low' | 'medium' | 'high' | 'max',
|
||||
): 'low' | 'medium' | 'high' | undefined {
|
||||
if (!effort) return undefined
|
||||
if (effort === 'max') return 'high'
|
||||
return effort
|
||||
}
|
||||
|
||||
function buildOpenCodeReasoning(
|
||||
body: ChatBody,
|
||||
): Record<string, unknown> | undefined {
|
||||
const reasoning: Record<string, unknown> = {}
|
||||
const effort = mapOpenCodeEffort(body.effort)
|
||||
if (effort) {
|
||||
reasoning.effort = effort
|
||||
}
|
||||
if (body.thinkingMode === 'enabled') {
|
||||
reasoning.enabled = true
|
||||
} else if (body.thinkingMode === 'disabled') {
|
||||
reasoning.enabled = false
|
||||
}
|
||||
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
|
||||
reasoning.budgetTokens = body.thinkingBudgetTokens
|
||||
}
|
||||
return Object.keys(reasoning).length > 0 ? reasoning : undefined
|
||||
}
|
||||
|
||||
async function promptOpenCodeWithThinking(
|
||||
ocClient: any,
|
||||
basePayload: Record<string, unknown>,
|
||||
body: ChatBody,
|
||||
): Promise<{ data: any; error: any }> {
|
||||
const reasoning = buildOpenCodeReasoning(body)
|
||||
if (!reasoning) {
|
||||
return await ocClient.session.prompt(basePayload)
|
||||
}
|
||||
|
||||
const enhanced = { ...basePayload, reasoning }
|
||||
const firstTry = await ocClient.session.prompt(enhanced)
|
||||
if (!firstTry.error) {
|
||||
return firstTry
|
||||
}
|
||||
|
||||
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.')
|
||||
return await ocClient.session.prompt(basePayload)
|
||||
}
|
||||
|
||||
function streamViaCodex(body: ChatBody, model?: string) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
const pingTimer = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
|
||||
} catch { /* stream already closed */ }
|
||||
}, KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
try {
|
||||
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
||||
const prompt = lastUserMsg?.content ?? ''
|
||||
const result = await runCodexExec(prompt, {
|
||||
model,
|
||||
systemPrompt: body.system,
|
||||
thinkingMode: body.thinkingMode,
|
||||
thinkingBudgetTokens: body.thinkingBudgetTokens,
|
||||
effort: body.effort,
|
||||
})
|
||||
|
||||
clearInterval(pingTimer)
|
||||
if (result.error) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content: result.error })}\n\n`),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.text) {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'text', content: result.text })}\n\n`),
|
||||
)
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
|
||||
)
|
||||
} catch (error) {
|
||||
const content = error instanceof Error ? error.message : 'Unknown error'
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||
)
|
||||
} finally {
|
||||
clearInterval(pingTimer)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream)
|
||||
}
|
||||
|
||||
/** Stream via OpenCode SDK (connects to a running OpenCode server) */
|
||||
function streamViaOpenCode(body: ChatBody, model?: string) {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
const pingTimer = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`))
|
||||
} catch { /* stream already closed */ }
|
||||
}, KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
let ocServer: { close(): void } | undefined
|
||||
try {
|
||||
const { getOpencodeClient } = await import('../../utils/opencode-client')
|
||||
const oc = await getOpencodeClient()
|
||||
const ocClient = oc.client
|
||||
ocServer = oc.server
|
||||
|
||||
// Create a session for this conversation
|
||||
const { data: session, error: sessionError } = await ocClient.session.create({
|
||||
title: 'OpenPencil Chat',
|
||||
})
|
||||
if (sessionError || !session) {
|
||||
throw new Error('Failed to create OpenCode session')
|
||||
}
|
||||
|
||||
// Inject system prompt as context (no AI reply)
|
||||
await ocClient.session.prompt({
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [{ type: 'text', text: body.system }],
|
||||
})
|
||||
|
||||
// Build prompt from the last user message
|
||||
const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user')
|
||||
const prompt = lastUserMsg?.content ?? ''
|
||||
|
||||
const parsed = parseOpenCodeModel(model)
|
||||
|
||||
// Send prompt and await full response
|
||||
const promptPayload: Record<string, unknown> = {
|
||||
sessionID: session.id,
|
||||
...(parsed ? { model: parsed } : {}),
|
||||
parts: [{ type: 'text', text: prompt }],
|
||||
}
|
||||
|
||||
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
|
||||
ocClient,
|
||||
promptPayload,
|
||||
body,
|
||||
)
|
||||
|
||||
if (promptError) {
|
||||
throw new Error('OpenCode prompt failed')
|
||||
}
|
||||
|
||||
// Extract text from response parts
|
||||
clearInterval(pingTimer)
|
||||
if (result?.parts) {
|
||||
for (const part of result.parts) {
|
||||
if (part.type === 'text' && 'text' in part) {
|
||||
const data = JSON.stringify({ type: 'text', content: part.text })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`),
|
||||
)
|
||||
} catch (error) {
|
||||
const content = error instanceof Error ? error.message : 'Unknown error'
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`),
|
||||
)
|
||||
} finally {
|
||||
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
|
||||
releaseOpencodeServer(ocServer)
|
||||
clearInterval(pingTimer)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import type { GroupedModel } from '../../../src/types/agent-settings'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
|
||||
interface ConnectBody {
|
||||
agent: 'claude-code' | 'codex-cli'
|
||||
agent: 'claude-code' | 'codex-cli' | 'opencode'
|
||||
}
|
||||
|
||||
interface ConnectResult {
|
||||
|
|
@ -31,6 +32,10 @@ export default defineEventHandler(async (event) => {
|
|||
return connectCodexCli()
|
||||
}
|
||||
|
||||
if (body.agent === 'opencode') {
|
||||
return connectOpenCode()
|
||||
}
|
||||
|
||||
return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult
|
||||
})
|
||||
|
||||
|
|
@ -42,6 +47,8 @@ async function connectClaudeCode(): Promise<ConnectResult> {
|
|||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
|
||||
const q = query({
|
||||
prompt: '',
|
||||
options: {
|
||||
|
|
@ -51,6 +58,7 @@ async function connectClaudeCode(): Promise<ConnectResult> {
|
|||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -151,3 +159,54 @@ async function connectCodexCli(): Promise<ConnectResult> {
|
|||
return { connected: false, models: [], error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/** Connect to OpenCode and fetch its configured providers/models. */
|
||||
async function connectOpenCode(): Promise<ConnectResult> {
|
||||
try {
|
||||
const { getOpencodeClient, releaseOpencodeServer } = await import('../../utils/opencode-client')
|
||||
const { client, server } = await getOpencodeClient()
|
||||
|
||||
const { data, error } = await client.config.providers()
|
||||
releaseOpencodeServer(server)
|
||||
|
||||
if (error) {
|
||||
return { connected: false, models: [], error: 'Failed to fetch providers from OpenCode server.' }
|
||||
}
|
||||
|
||||
const models: GroupedModel[] = []
|
||||
for (const provider of data?.providers ?? []) {
|
||||
if (!provider.models) continue
|
||||
for (const [, model] of Object.entries(provider.models)) {
|
||||
models.push({
|
||||
value: `${provider.id}/${model.id}`,
|
||||
displayName: model.name || model.id,
|
||||
description: `via ${provider.name || provider.id}`,
|
||||
provider: 'opencode' as const,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (models.length === 0) {
|
||||
return { connected: false, models: [], error: 'No models configured in OpenCode. Run "opencode" to set up providers.' }
|
||||
}
|
||||
|
||||
return { connected: true, models }
|
||||
} catch (error) {
|
||||
const raw = error instanceof Error ? error.message : 'Failed to connect'
|
||||
return { connected: false, models: [], error: friendlyOpenCodeError(raw) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Map OpenCode connection errors to user-friendly messages */
|
||||
function friendlyOpenCodeError(raw: string): string {
|
||||
if (/ECONNREFUSED/i.test(raw)) {
|
||||
return 'OpenCode server not running. Start it with "opencode" in your terminal first.'
|
||||
}
|
||||
if (/not found|ENOENT/i.test(raw)) {
|
||||
return 'OpenCode CLI not found. Please install it first.'
|
||||
}
|
||||
if (/timed?\s*out/i.test(raw)) {
|
||||
return 'Connection timed out. Please try again.'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { resolveClaudeCli } from '../../utils/resolve-claude-cli'
|
||||
import { runCodexExec } from '../../utils/codex-client'
|
||||
|
||||
interface GenerateBody {
|
||||
system: string
|
||||
message: string
|
||||
model?: string
|
||||
provider?: string
|
||||
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
|
||||
thinkingBudgetTokens?: number
|
||||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,6 +25,15 @@ export default defineEventHandler(async (event) => {
|
|||
return { error: 'Missing required fields: system, message' }
|
||||
}
|
||||
|
||||
// Explicit provider routing
|
||||
if (body.provider === 'opencode') {
|
||||
return generateViaOpenCode(body, body.model)
|
||||
}
|
||||
if (body.provider === 'openai') {
|
||||
return generateViaCodex(body, body.model)
|
||||
}
|
||||
|
||||
// Default: existing behavior (backward-compatible)
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
if (apiKey) {
|
||||
try {
|
||||
|
|
@ -59,6 +74,8 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
|
|||
const env = { ...process.env } as Record<string, string | undefined>
|
||||
delete env.CLAUDECODE
|
||||
|
||||
const claudePath = resolveClaudeCli()
|
||||
|
||||
const q = query({
|
||||
prompt: body.message,
|
||||
options: {
|
||||
|
|
@ -66,9 +83,11 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
|
|||
model: model || 'claude-sonnet-4-6',
|
||||
maxTurns: 1,
|
||||
tools: [],
|
||||
plugins: [],
|
||||
permissionMode: 'plan',
|
||||
persistSession: false,
|
||||
env,
|
||||
...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -88,3 +107,128 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<
|
|||
return { error: message }
|
||||
}
|
||||
}
|
||||
|
||||
async function generateViaCodex(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
|
||||
const result = await runCodexExec(body.message, {
|
||||
model,
|
||||
systemPrompt: body.system,
|
||||
thinkingMode: body.thinkingMode,
|
||||
thinkingBudgetTokens: body.thinkingBudgetTokens,
|
||||
effort: body.effort,
|
||||
})
|
||||
return result.error ? { error: result.error } : { text: result.text ?? '' }
|
||||
}
|
||||
|
||||
function mapOpenCodeEffort(
|
||||
effort?: 'low' | 'medium' | 'high' | 'max',
|
||||
): 'low' | 'medium' | 'high' | undefined {
|
||||
if (!effort) return undefined
|
||||
if (effort === 'max') return 'high'
|
||||
return effort
|
||||
}
|
||||
|
||||
function buildOpenCodeReasoning(
|
||||
body: GenerateBody,
|
||||
): Record<string, unknown> | undefined {
|
||||
const reasoning: Record<string, unknown> = {}
|
||||
const effort = mapOpenCodeEffort(body.effort)
|
||||
if (effort) {
|
||||
reasoning.effort = effort
|
||||
}
|
||||
if (body.thinkingMode === 'enabled') {
|
||||
reasoning.enabled = true
|
||||
} else if (body.thinkingMode === 'disabled') {
|
||||
reasoning.enabled = false
|
||||
}
|
||||
if (typeof body.thinkingBudgetTokens === 'number' && body.thinkingBudgetTokens > 0) {
|
||||
reasoning.budgetTokens = body.thinkingBudgetTokens
|
||||
}
|
||||
return Object.keys(reasoning).length > 0 ? reasoning : undefined
|
||||
}
|
||||
|
||||
async function promptOpenCodeWithThinking(
|
||||
ocClient: any,
|
||||
basePayload: Record<string, unknown>,
|
||||
body: GenerateBody,
|
||||
): Promise<{ data: any; error: any }> {
|
||||
const reasoning = buildOpenCodeReasoning(body)
|
||||
if (!reasoning) {
|
||||
return await ocClient.session.prompt(basePayload)
|
||||
}
|
||||
|
||||
const enhanced = { ...basePayload, reasoning }
|
||||
const firstTry = await ocClient.session.prompt(enhanced)
|
||||
if (!firstTry.error) {
|
||||
return firstTry
|
||||
}
|
||||
|
||||
console.warn('[AI] OpenCode reasoning options rejected, retrying without reasoning.')
|
||||
return await ocClient.session.prompt(basePayload)
|
||||
}
|
||||
|
||||
/** Generate via OpenCode SDK (connects to a running OpenCode server) */
|
||||
async function generateViaOpenCode(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> {
|
||||
let ocServer: { close(): void } | undefined
|
||||
try {
|
||||
const { getOpencodeClient } = await import('../../utils/opencode-client')
|
||||
const oc = await getOpencodeClient()
|
||||
const ocClient = oc.client
|
||||
ocServer = oc.server
|
||||
|
||||
const { data: session, error: sessionError } = await ocClient.session.create({
|
||||
title: 'OpenPencil Generate',
|
||||
})
|
||||
if (sessionError || !session) {
|
||||
return { error: 'Failed to create OpenCode session' }
|
||||
}
|
||||
|
||||
// Inject system prompt as context (no AI reply)
|
||||
await ocClient.session.prompt({
|
||||
sessionID: session.id,
|
||||
noReply: true,
|
||||
parts: [{ type: 'text', text: body.system }],
|
||||
})
|
||||
|
||||
// Parse model string ("providerID/modelID")
|
||||
let modelOption: { providerID: string; modelID: string } | undefined
|
||||
if (model && model.includes('/')) {
|
||||
const idx = model.indexOf('/')
|
||||
modelOption = { providerID: model.slice(0, idx), modelID: model.slice(idx + 1) }
|
||||
}
|
||||
|
||||
// Send main prompt and await full response
|
||||
const promptPayload: Record<string, unknown> = {
|
||||
sessionID: session.id,
|
||||
...(modelOption ? { model: modelOption } : {}),
|
||||
parts: [{ type: 'text', text: body.message }],
|
||||
}
|
||||
|
||||
const { data: result, error: promptError } = await promptOpenCodeWithThinking(
|
||||
ocClient,
|
||||
promptPayload,
|
||||
body,
|
||||
)
|
||||
|
||||
if (promptError) {
|
||||
return { error: 'OpenCode generation failed' }
|
||||
}
|
||||
|
||||
// Extract text from response parts
|
||||
const texts: string[] = []
|
||||
if (result?.parts) {
|
||||
for (const part of result.parts) {
|
||||
if (part.type === 'text' && part.text) {
|
||||
texts.push(part.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { text: texts.join('') }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { error: message }
|
||||
} finally {
|
||||
const { releaseOpencodeServer } = await import('../../utils/opencode-client')
|
||||
releaseOpencodeServer(ocServer)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
201
server/api/ai/mcp-install.ts
Normal file
201
server/api/ai/mcp-install.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { defineEventHandler, readBody, setResponseHeaders } from 'h3'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
|
||||
interface InstallBody {
|
||||
tool: string
|
||||
action: 'install' | 'uninstall'
|
||||
}
|
||||
|
||||
interface InstallResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
configPath?: string
|
||||
}
|
||||
|
||||
const MCP_SERVER_NAME = 'openpencil'
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the compiled MCP server.
|
||||
* In dev: <project>/dist/mcp-server.cjs
|
||||
* In production (Electron): <resources>/mcp-server.cjs
|
||||
*/
|
||||
function resolveMcpServerPath(): string {
|
||||
// Electron production: extraResources places it in resourcesPath
|
||||
const electronResources = process.env.ELECTRON_RESOURCES_PATH
|
||||
if (electronResources) {
|
||||
const electronPath = join(electronResources, 'mcp-server.cjs')
|
||||
if (existsSync(electronPath)) return electronPath
|
||||
}
|
||||
// Try dist/ in the project root (dev + web build)
|
||||
const projectDist = resolve(process.cwd(), 'dist', 'mcp-server.cjs')
|
||||
if (existsSync(projectDist)) return projectDist
|
||||
// Fallback: try relative to this file
|
||||
const serverDist = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs')
|
||||
if (existsSync(serverDist)) return serverDist
|
||||
return projectDist // Return expected path even if not yet compiled
|
||||
}
|
||||
|
||||
/** Config file locations and formats for each CLI tool. */
|
||||
const CLI_CONFIGS: Record<
|
||||
string,
|
||||
{
|
||||
configPath: () => string
|
||||
read: (filePath: string) => Promise<Record<string, any>>
|
||||
write: (filePath: string, config: Record<string, any>) => Promise<void>
|
||||
install: (config: Record<string, any>, serverPath: string) => Record<string, any>
|
||||
uninstall: (config: Record<string, any>) => Record<string, any>
|
||||
}
|
||||
> = {
|
||||
'claude-code': {
|
||||
configPath: () => join(homedir(), '.claude.json'),
|
||||
read: readJsonConfig,
|
||||
write: writeJsonConfig,
|
||||
install: (config, serverPath) => ({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(config.mcpServers ?? {}),
|
||||
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
|
||||
},
|
||||
}),
|
||||
uninstall: (config) => {
|
||||
const servers = { ...(config.mcpServers ?? {}) }
|
||||
delete servers[MCP_SERVER_NAME]
|
||||
return {
|
||||
...config,
|
||||
mcpServers: Object.keys(servers).length > 0 ? servers : undefined,
|
||||
}
|
||||
},
|
||||
},
|
||||
'codex-cli': {
|
||||
configPath: () => join(homedir(), '.codex', 'config.json'),
|
||||
read: readJsonConfig,
|
||||
write: writeJsonConfig,
|
||||
install: (config, serverPath) => ({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(config.mcpServers ?? {}),
|
||||
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
|
||||
},
|
||||
}),
|
||||
uninstall: (config) => {
|
||||
const servers = { ...(config.mcpServers ?? {}) }
|
||||
delete servers[MCP_SERVER_NAME]
|
||||
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
|
||||
},
|
||||
},
|
||||
'gemini-cli': {
|
||||
configPath: () => join(homedir(), '.gemini', 'settings.json'),
|
||||
read: readJsonConfig,
|
||||
write: writeJsonConfig,
|
||||
install: (config, serverPath) => ({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(config.mcpServers ?? {}),
|
||||
[MCP_SERVER_NAME]: {
|
||||
command: 'node',
|
||||
args: [serverPath],
|
||||
},
|
||||
},
|
||||
}),
|
||||
uninstall: (config) => {
|
||||
const servers = { ...(config.mcpServers ?? {}) }
|
||||
delete servers[MCP_SERVER_NAME]
|
||||
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
|
||||
},
|
||||
},
|
||||
'opencode-cli': {
|
||||
configPath: () => join(homedir(), '.opencode', 'config.json'),
|
||||
read: readJsonConfig,
|
||||
write: writeJsonConfig,
|
||||
install: (config, serverPath) => ({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(config.mcpServers ?? {}),
|
||||
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
|
||||
},
|
||||
}),
|
||||
uninstall: (config) => {
|
||||
const servers = { ...(config.mcpServers ?? {}) }
|
||||
delete servers[MCP_SERVER_NAME]
|
||||
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
|
||||
},
|
||||
},
|
||||
'kiro-cli': {
|
||||
configPath: () => join(homedir(), '.kiro', 'settings.json'),
|
||||
read: readJsonConfig,
|
||||
write: writeJsonConfig,
|
||||
install: (config, serverPath) => ({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...(config.mcpServers ?? {}),
|
||||
[MCP_SERVER_NAME]: { command: 'node', args: [serverPath] },
|
||||
},
|
||||
}),
|
||||
uninstall: (config) => {
|
||||
const servers = { ...(config.mcpServers ?? {}) }
|
||||
delete servers[MCP_SERVER_NAME]
|
||||
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
async function readJsonConfig(filePath: string): Promise<Record<string, any>> {
|
||||
try {
|
||||
const text = await readFile(filePath, 'utf-8')
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonConfig(
|
||||
filePath: string,
|
||||
config: Record<string, any>,
|
||||
): Promise<void> {
|
||||
const dir = join(filePath, '..')
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true })
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/mcp-install
|
||||
* Install or uninstall the openpencil MCP server into a CLI tool's config.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody<InstallBody>(event)
|
||||
setResponseHeaders(event, { 'Content-Type': 'application/json' })
|
||||
|
||||
if (!body?.tool || !body?.action) {
|
||||
return { success: false, error: 'Missing tool or action field' } satisfies InstallResult
|
||||
}
|
||||
|
||||
const cliConfig = CLI_CONFIGS[body.tool]
|
||||
if (!cliConfig) {
|
||||
return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = cliConfig.configPath()
|
||||
const config = await cliConfig.read(configPath)
|
||||
const serverPath = resolveMcpServerPath()
|
||||
|
||||
const updated =
|
||||
body.action === 'install'
|
||||
? cliConfig.install(config, serverPath)
|
||||
: cliConfig.uninstall(config)
|
||||
|
||||
await cliConfig.write(configPath, updated)
|
||||
|
||||
return { success: true, configPath } satisfies InstallResult
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
} satisfies InstallResult
|
||||
}
|
||||
})
|
||||
245
server/utils/codex-client.ts
Normal file
245
server/utils/codex-client.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
|
||||
type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
interface CodexExecOptions {
|
||||
model?: string
|
||||
systemPrompt?: string
|
||||
thinkingMode?: ThinkingMode
|
||||
thinkingBudgetTokens?: number
|
||||
effort?: ThinkingEffort
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
interface CodexCliResult {
|
||||
text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const DEFAULT_CODEX_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
export async function runCodexExec(
|
||||
userPrompt: string,
|
||||
options: CodexExecOptions = {},
|
||||
): Promise<CodexCliResult> {
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-codex-'))
|
||||
const outputPath = join(tempDir, 'last-message.txt')
|
||||
const prompt = buildPrompt(options.systemPrompt, userPrompt)
|
||||
const codexEffort = resolveCodexEffort(options.thinkingMode, options.effort)
|
||||
|
||||
const args = [
|
||||
'exec',
|
||||
'--json',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'--output-last-message',
|
||||
outputPath,
|
||||
]
|
||||
|
||||
if (options.model) {
|
||||
args.push('--model', options.model)
|
||||
}
|
||||
|
||||
if (codexEffort) {
|
||||
args.push('--config', `model_reasoning_effort="${codexEffort}"`)
|
||||
}
|
||||
|
||||
args.push(prompt)
|
||||
|
||||
try {
|
||||
const runResult = await executeCodexCommand(
|
||||
args,
|
||||
options.timeoutMs ?? DEFAULT_CODEX_TIMEOUT_MS,
|
||||
)
|
||||
const finalText = await readFile(outputPath, 'utf-8').catch(() => '')
|
||||
const normalizedText = finalText.trim() || runResult.text.trim()
|
||||
|
||||
if (normalizedText) {
|
||||
return { text: normalizedText }
|
||||
}
|
||||
|
||||
if (runResult.errors.length > 0) {
|
||||
return { error: runResult.errors.join('; ') }
|
||||
}
|
||||
|
||||
return { error: 'Codex returned no output.' }
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Codex execution failed' }
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string {
|
||||
const userText = userPrompt.trim()
|
||||
if (!systemPrompt?.trim()) {
|
||||
return userText
|
||||
}
|
||||
|
||||
return [
|
||||
'SYSTEM INSTRUCTIONS:',
|
||||
systemPrompt.trim(),
|
||||
'',
|
||||
'USER REQUEST:',
|
||||
userText,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function resolveCodexEffort(
|
||||
thinkingMode: ThinkingMode | undefined,
|
||||
effort: ThinkingEffort | undefined,
|
||||
): 'low' | 'medium' | 'high' | undefined {
|
||||
if (thinkingMode === 'disabled') {
|
||||
return 'low'
|
||||
}
|
||||
|
||||
if (effort === 'max') {
|
||||
return 'high'
|
||||
}
|
||||
|
||||
if (effort === 'low' || effort === 'medium' || effort === 'high') {
|
||||
return effort
|
||||
}
|
||||
|
||||
if (thinkingMode === 'enabled') {
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function executeCodexCommand(
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
): Promise<{ text: string; errors: string[] }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn('codex', args, {
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
let stdoutBuffer = ''
|
||||
let stderrBuffer = ''
|
||||
let textAccumulator = ''
|
||||
const errors: string[] = []
|
||||
|
||||
const flushStdoutLine = (line: string) => {
|
||||
const event = parseCodexJsonLine(line)
|
||||
if (!event) return
|
||||
if (event.text) {
|
||||
textAccumulator += event.text
|
||||
}
|
||||
if (event.error) {
|
||||
errors.push(event.error)
|
||||
}
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGTERM')
|
||||
reject(new Error(`Codex request timed out after ${Math.round(timeoutMs / 1000)}s.`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString('utf-8')
|
||||
let idx = stdoutBuffer.indexOf('\n')
|
||||
while (idx >= 0) {
|
||||
const line = stdoutBuffer.slice(0, idx).trim()
|
||||
stdoutBuffer = stdoutBuffer.slice(idx + 1)
|
||||
if (line) flushStdoutLine(line)
|
||||
idx = stdoutBuffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuffer += chunk.toString('utf-8')
|
||||
})
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
|
||||
const tail = stdoutBuffer.trim()
|
||||
if (tail) {
|
||||
flushStdoutLine(tail)
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve({ text: textAccumulator, errors })
|
||||
return
|
||||
}
|
||||
|
||||
const stderrError = extractCodexCliError(stderrBuffer)
|
||||
const fallback = errors[errors.length - 1]
|
||||
reject(
|
||||
new Error(
|
||||
stderrError
|
||||
|| fallback
|
||||
|| `Codex exited with code ${code ?? 'unknown'}.`,
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parseCodexJsonLine(
|
||||
line: string,
|
||||
): { text?: string; error?: string } | null {
|
||||
let parsed: Record<string, unknown>
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const type = typeof parsed.type === 'string' ? parsed.type : ''
|
||||
if (type === 'error') {
|
||||
const message = getStringField(parsed, ['message'])
|
||||
return { error: message || 'Codex returned an unknown error.' }
|
||||
}
|
||||
|
||||
// Common Codex JSONL stream events include deltas in "delta" or "text".
|
||||
const text =
|
||||
getStringField(parsed, ['delta'])
|
||||
|| getStringField(parsed, ['text'])
|
||||
|| getStringField(parsed, ['content'])
|
||||
|
||||
if (!text) return null
|
||||
return { text }
|
||||
}
|
||||
|
||||
function getStringField(
|
||||
obj: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): string | null {
|
||||
for (const key of keys) {
|
||||
const val = obj[key]
|
||||
if (typeof val === 'string' && val.length > 0) {
|
||||
return val
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractCodexCliError(stderr: string): string | null {
|
||||
const trimmed = stderr.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const lines = trimmed.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i]
|
||||
if (line.toLowerCase().startsWith('error:')) {
|
||||
return line.replace(/^error:\s*/i, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return lines[lines.length - 1] ?? null
|
||||
}
|
||||
41
server/utils/opencode-client.ts
Normal file
41
server/utils/opencode-client.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Shared OpenCode client manager.
|
||||
* Reuses an existing server on port 4096; starts one on a random port as fallback.
|
||||
* Tracks spawned servers so they can be cleaned up on process exit.
|
||||
*/
|
||||
|
||||
const activeServers = new Set<{ close(): void }>()
|
||||
|
||||
// Clean up spawned OpenCode servers on process exit
|
||||
function cleanup() {
|
||||
for (const server of activeServers) {
|
||||
try { server.close() } catch { /* ignore */ }
|
||||
}
|
||||
activeServers.clear()
|
||||
}
|
||||
|
||||
process.on('beforeExit', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
process.on('SIGINT', cleanup)
|
||||
|
||||
export async function getOpencodeClient() {
|
||||
const { createOpencodeClient, createOpencode } = await import('@opencode-ai/sdk/v2')
|
||||
|
||||
// Try connecting to an existing server first
|
||||
try {
|
||||
const client = createOpencodeClient()
|
||||
await client.config.providers() // probe
|
||||
return { client, server: undefined }
|
||||
} catch {
|
||||
// No running server — start a temporary one on a random port
|
||||
const oc = await createOpencode({ port: 0 })
|
||||
activeServers.add(oc.server)
|
||||
return { client: oc.client, server: oc.server }
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseOpencodeServer(server: { close(): void } | undefined) {
|
||||
if (!server) return
|
||||
try { server.close() } catch { /* ignore */ }
|
||||
activeServers.delete(server)
|
||||
}
|
||||
36
server/utils/resolve-claude-cli.ts
Normal file
36
server/utils/resolve-claude-cli.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { execSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Resolve the absolute path to the standalone `claude` binary.
|
||||
*
|
||||
* When Nitro bundles @anthropic-ai/claude-agent-sdk, the SDK's internal
|
||||
* `import.meta.url`-based resolution to find its own `cli.js` breaks.
|
||||
* Instead we locate the standalone native binary and pass it via
|
||||
* `pathToClaudeCodeExecutable` — the SDK detects non-.js paths as native
|
||||
* binaries and spawns them directly (no `node` wrapper needed).
|
||||
*/
|
||||
export function resolveClaudeCli(): string | undefined {
|
||||
// 1. Try `which claude` (works when PATH is correctly set)
|
||||
try {
|
||||
const p = execSync('which claude 2>/dev/null', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 3000,
|
||||
}).trim()
|
||||
if (p && existsSync(p)) return p
|
||||
} catch { /* not in PATH */ }
|
||||
|
||||
// 2. Common install locations
|
||||
const candidates = [
|
||||
join(homedir(), '.local', 'bin', 'claude'),
|
||||
'/usr/local/bin/claude',
|
||||
'/opt/homebrew/bin/claude',
|
||||
]
|
||||
for (const c of candidates) {
|
||||
if (existsSync(c)) return c
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
|
@ -15,6 +15,8 @@ export function getCanvasBackground(): string {
|
|||
: CANVAS_BACKGROUND_DARK
|
||||
}
|
||||
export const SELECTION_BLUE = '#0d99ff'
|
||||
export const COMPONENT_COLOR = '#a855f7'
|
||||
export const INSTANCE_COLOR = '#9281f7'
|
||||
|
||||
// Smart guides
|
||||
export const GUIDE_COLOR = '#FF6B35'
|
||||
|
|
|
|||
|
|
@ -316,12 +316,18 @@ export function createFabricObject(
|
|||
underline: node.underline ?? false,
|
||||
linethrough: node.strikethrough ?? false,
|
||||
lineHeight: node.lineHeight ?? 1.2,
|
||||
charSpacing: node.letterSpacing
|
||||
? (node.letterSpacing / (node.fontSize || 16)) * 1000
|
||||
: 0,
|
||||
}
|
||||
// Use Textbox for text with explicit width so textAlign works correctly
|
||||
if (w > 0) {
|
||||
// Use Textbox for fixed-width / fixed-size modes (word wrapping).
|
||||
// Use IText for auto-width mode (no wrapping, expands horizontally).
|
||||
const growth = node.textGrowth
|
||||
const useTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
|
||||
if (useTextbox) {
|
||||
obj = new fabric.Textbox(textContent, {
|
||||
...textProps,
|
||||
width: w,
|
||||
width: w > 0 ? w : 200,
|
||||
}) as FabricObjectWithPenId
|
||||
} else {
|
||||
obj = new fabric.IText(textContent, textProps) as FabricObjectWithPenId
|
||||
|
|
|
|||
|
|
@ -115,15 +115,19 @@ export function syncFabricObject(
|
|||
? node.content
|
||||
: node.content.map((s) => s.text).join('')
|
||||
const w = sizeToNumber(node.width, 0)
|
||||
const fontSize = node.fontSize ?? 16
|
||||
obj.set({
|
||||
text: content,
|
||||
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
|
||||
fontSize: node.fontSize ?? 16,
|
||||
fontSize,
|
||||
fontWeight: (node.fontWeight as string) ?? 'normal',
|
||||
fontStyle: node.fontStyle ?? 'normal',
|
||||
fill: resolveFillColor(node.fill),
|
||||
textAlign: node.textAlign ?? 'left',
|
||||
lineHeight: node.lineHeight ?? 1.2,
|
||||
charSpacing: node.letterSpacing
|
||||
? (node.letterSpacing / fontSize) * 1000
|
||||
: 0,
|
||||
})
|
||||
if (w > 0) obj.set({ width: w })
|
||||
break
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type * as fabric from 'fabric'
|
|||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
import { setFabricSyncLock } from './canvas-sync-lock'
|
||||
import { layoutContainerBounds } from './use-canvas-sync'
|
||||
import { layoutContainerBounds, rootFrameBounds } from './use-canvas-sync'
|
||||
import type { LayoutContainerInfo } from './use-canvas-sync'
|
||||
import { setInsertionIndicator, setContainerHighlight } from './insertion-indicator'
|
||||
|
||||
|
|
@ -12,8 +12,11 @@ import { setInsertionIndicator, setContainerHighlight } from './insertion-indica
|
|||
|
||||
interface DragIntoSession {
|
||||
nodeId: string
|
||||
nodeIds?: string[]
|
||||
targetContainerId: string
|
||||
insertionIndex: number
|
||||
/** Whether the target is a layout container (vertical/horizontal) vs a plain frame */
|
||||
isLayoutTarget: boolean
|
||||
}
|
||||
|
||||
let activeSession: DragIntoSession | null = null
|
||||
|
|
@ -162,21 +165,23 @@ export function checkDragIntoTarget(
|
|||
const objCenterY =
|
||||
(obj.top ?? 0) + ((obj.height ?? 0) * (obj.scaleY ?? 1)) / 2
|
||||
|
||||
// Find the best (innermost) layout container containing the center.
|
||||
// Innermost = smallest area among matching containers.
|
||||
// Find the best (innermost) container containing the center.
|
||||
// Check layout containers first (they support insertion indicators),
|
||||
// then non-layout root frames (just container highlight).
|
||||
const parent = store.getParentOf(nodeId)
|
||||
|
||||
let bestContainerId: string | null = null
|
||||
let bestInfo: LayoutContainerInfo | null = null
|
||||
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
|
||||
let bestArea = Infinity
|
||||
let bestIsLayout = false
|
||||
|
||||
// 1. Check layout containers (preferred — support insertion indicators)
|
||||
for (const [containerId, info] of layoutContainerBounds) {
|
||||
// Skip if the dragged node is already a direct child of this container
|
||||
const parent = store.getParentOf(nodeId)
|
||||
if (parent?.id === containerId) continue
|
||||
|
||||
// Skip if the container is a descendant of the dragged node (prevent cycles)
|
||||
if (store.isDescendantOf(containerId, nodeId)) continue
|
||||
if (containerId === nodeId) continue
|
||||
|
||||
// Hit test: is the center inside the container bounds?
|
||||
if (
|
||||
objCenterX >= info.x &&
|
||||
objCenterX <= info.x + info.w &&
|
||||
|
|
@ -188,11 +193,37 @@ export function checkDragIntoTarget(
|
|||
bestArea = area
|
||||
bestContainerId = containerId
|
||||
bestInfo = info
|
||||
bestIsLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestContainerId && bestInfo) {
|
||||
// 2. Check non-layout root frames (just container highlight, no indicator)
|
||||
for (const [frameId, bounds] of rootFrameBounds) {
|
||||
if (layoutContainerBounds.has(frameId)) continue // already checked above
|
||||
if (parent?.id === frameId) continue
|
||||
if (store.isDescendantOf(frameId, nodeId)) continue
|
||||
if (frameId === nodeId) continue
|
||||
|
||||
if (
|
||||
objCenterX >= bounds.x &&
|
||||
objCenterX <= bounds.x + bounds.w &&
|
||||
objCenterY >= bounds.y &&
|
||||
objCenterY <= bounds.y + bounds.h
|
||||
) {
|
||||
const area = bounds.w * bounds.h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestContainerId = frameId
|
||||
bestInfo = null
|
||||
bestFrameBounds = bounds
|
||||
bestIsLayout = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestContainerId && bestIsLayout && bestInfo) {
|
||||
// Layout container: show insertion indicator + container highlight
|
||||
const fabObjectMap = buildFabObjectMap(canvas)
|
||||
const container = store.getNodeById(bestContainerId)
|
||||
const childIds =
|
||||
|
|
@ -213,6 +244,7 @@ export function checkDragIntoTarget(
|
|||
nodeId,
|
||||
targetContainerId: bestContainerId,
|
||||
insertionIndex: insertIndex,
|
||||
isLayoutTarget: true,
|
||||
}
|
||||
|
||||
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
|
||||
|
|
@ -223,9 +255,34 @@ export function checkDragIntoTarget(
|
|||
h: bestInfo.h,
|
||||
})
|
||||
|
||||
canvas.requestRenderAll()
|
||||
} else if (bestContainerId && !bestIsLayout) {
|
||||
// Non-layout frame: show container highlight only, append at end
|
||||
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
|
||||
const container = store.getNodeById(bestContainerId)
|
||||
const childCount =
|
||||
container && 'children' in container && container.children
|
||||
? container.children.length
|
||||
: 0
|
||||
|
||||
activeSession = {
|
||||
nodeId,
|
||||
targetContainerId: bestContainerId,
|
||||
insertionIndex: childCount,
|
||||
isLayoutTarget: false,
|
||||
}
|
||||
|
||||
setInsertionIndicator(null)
|
||||
setContainerHighlight({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
w: bounds.w,
|
||||
h: bounds.h,
|
||||
})
|
||||
|
||||
canvas.requestRenderAll()
|
||||
} else {
|
||||
// Not over any layout container — clear
|
||||
// Not over any container — clear
|
||||
if (activeSession) {
|
||||
activeSession = null
|
||||
setInsertionIndicator(null)
|
||||
|
|
@ -240,20 +297,31 @@ export function checkDragIntoTarget(
|
|||
* Returns true if a reparent was performed.
|
||||
*/
|
||||
export function commitDragInto(
|
||||
_obj: FabricObjectWithPenId,
|
||||
obj: FabricObjectWithPenId,
|
||||
canvas: fabric.Canvas,
|
||||
): boolean {
|
||||
if (!activeSession) return false
|
||||
|
||||
const { nodeId, targetContainerId, insertionIndex } = activeSession
|
||||
const { nodeId, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
|
||||
|
||||
setFabricSyncLock(true)
|
||||
|
||||
// Clear manual position so layout engine takes over
|
||||
useDocumentStore.getState().updateNode(nodeId, {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
} as Partial<import('@/types/pen').PenNode>)
|
||||
if (isLayoutTarget) {
|
||||
// Clear manual position so layout engine takes over
|
||||
useDocumentStore.getState().updateNode(nodeId, {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
} as Partial<import('@/types/pen').PenNode>)
|
||||
} else {
|
||||
// Non-layout frame: convert absolute position to relative
|
||||
const targetBounds = rootFrameBounds.get(targetContainerId)
|
||||
if (targetBounds) {
|
||||
useDocumentStore.getState().updateNode(nodeId, {
|
||||
x: (obj.left ?? 0) - targetBounds.x,
|
||||
y: (obj.top ?? 0) - targetBounds.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reparent into the target container
|
||||
useDocumentStore.getState().moveNode(nodeId, targetContainerId, insertionIndex)
|
||||
|
|
@ -276,6 +344,235 @@ export function commitDragInto(
|
|||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-selection version of checkDragIntoTarget.
|
||||
* Uses a given center point and list of node IDs.
|
||||
*/
|
||||
export function checkDragIntoTargetMulti(
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
nodeIds: string[],
|
||||
canvas: fabric.Canvas,
|
||||
): void {
|
||||
if (nodeIds.length === 0) return
|
||||
|
||||
const store = useDocumentStore.getState()
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
|
||||
let bestContainerId: string | null = null
|
||||
let bestInfo: LayoutContainerInfo | null = null
|
||||
let bestFrameBounds: { x: number; y: number; w: number; h: number } | null = null
|
||||
let bestArea = Infinity
|
||||
let bestIsLayout = false
|
||||
|
||||
// 1. Check layout containers (preferred — support insertion indicators)
|
||||
for (const [containerId, info] of layoutContainerBounds) {
|
||||
if (nodeIdSet.has(containerId)) continue
|
||||
if (nodeIds.some((nid) => store.isDescendantOf(containerId, nid))) continue
|
||||
|
||||
if (
|
||||
centerX >= info.x &&
|
||||
centerX <= info.x + info.w &&
|
||||
centerY >= info.y &&
|
||||
centerY <= info.y + info.h
|
||||
) {
|
||||
const area = info.w * info.h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestContainerId = containerId
|
||||
bestInfo = info
|
||||
bestIsLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check non-layout root frames
|
||||
for (const [frameId, bounds] of rootFrameBounds) {
|
||||
if (layoutContainerBounds.has(frameId)) continue
|
||||
if (nodeIdSet.has(frameId)) continue
|
||||
if (nodeIds.some((nid) => store.isDescendantOf(frameId, nid))) continue
|
||||
|
||||
if (
|
||||
centerX >= bounds.x &&
|
||||
centerX <= bounds.x + bounds.w &&
|
||||
centerY >= bounds.y &&
|
||||
centerY <= bounds.y + bounds.h
|
||||
) {
|
||||
const area = bounds.w * bounds.h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestContainerId = frameId
|
||||
bestInfo = null
|
||||
bestFrameBounds = bounds
|
||||
bestIsLayout = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestContainerId && bestIsLayout && bestInfo) {
|
||||
// Layout container: show insertion indicator + container highlight
|
||||
const fabObjectMap = buildFabObjectMap(canvas)
|
||||
const container = store.getNodeById(bestContainerId)
|
||||
const childIds =
|
||||
container && 'children' in container && container.children
|
||||
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
|
||||
: []
|
||||
|
||||
const isVertical = bestInfo.layout === 'vertical'
|
||||
const mainCenter = isVertical ? centerY : centerX
|
||||
const insertIndex = calcInsertionIndex(
|
||||
mainCenter,
|
||||
bestInfo,
|
||||
childIds,
|
||||
fabObjectMap,
|
||||
)
|
||||
|
||||
activeSession = {
|
||||
nodeId: nodeIds[0],
|
||||
nodeIds,
|
||||
targetContainerId: bestContainerId,
|
||||
insertionIndex: insertIndex,
|
||||
isLayoutTarget: true,
|
||||
}
|
||||
|
||||
computeIndicator(bestInfo, childIds, insertIndex, fabObjectMap)
|
||||
setContainerHighlight({
|
||||
x: bestInfo.x,
|
||||
y: bestInfo.y,
|
||||
w: bestInfo.w,
|
||||
h: bestInfo.h,
|
||||
})
|
||||
|
||||
canvas.requestRenderAll()
|
||||
} else if (bestContainerId && !bestIsLayout) {
|
||||
// Non-layout frame: show container highlight only, append at end
|
||||
const bounds = bestFrameBounds ?? rootFrameBounds.get(bestContainerId)!
|
||||
const container = store.getNodeById(bestContainerId)
|
||||
const childIds =
|
||||
container && 'children' in container && container.children
|
||||
? container.children.map((c) => c.id).filter((id) => !nodeIdSet.has(id))
|
||||
: []
|
||||
|
||||
activeSession = {
|
||||
nodeId: nodeIds[0],
|
||||
nodeIds,
|
||||
targetContainerId: bestContainerId,
|
||||
insertionIndex: childIds.length,
|
||||
isLayoutTarget: false,
|
||||
}
|
||||
|
||||
setInsertionIndicator(null)
|
||||
setContainerHighlight({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
w: bounds.w,
|
||||
h: bounds.h,
|
||||
})
|
||||
|
||||
canvas.requestRenderAll()
|
||||
} else {
|
||||
if (activeSession) {
|
||||
activeSession = null
|
||||
setInsertionIndicator(null)
|
||||
setContainerHighlight(null)
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit drag-into for multi-selection: reparent or reorder nodes.
|
||||
* Handles both cross-container reparent and same-container reorder.
|
||||
* Returns true if a reparent/reorder was performed.
|
||||
*/
|
||||
export function commitDragIntoMulti(
|
||||
canvas: fabric.Canvas,
|
||||
): boolean {
|
||||
if (!activeSession || !activeSession.nodeIds) return false
|
||||
|
||||
const { nodeIds, targetContainerId, insertionIndex, isLayoutTarget } = activeSession
|
||||
const store = useDocumentStore.getState()
|
||||
|
||||
// Check if this is a same-container reorder
|
||||
const firstParent = store.getParentOf(nodeIds[0])
|
||||
const isSameContainer = firstParent?.id === targetContainerId
|
||||
|
||||
setFabricSyncLock(true)
|
||||
|
||||
if (isSameContainer) {
|
||||
// Same container: reorder by manipulating children array directly.
|
||||
// This avoids shifting-index issues that arise when calling moveNode
|
||||
// in a loop (each removal shifts subsequent indices).
|
||||
const container = store.getNodeById(targetContainerId)
|
||||
if (container && 'children' in container && container.children) {
|
||||
const nodeIdSet = new Set(nodeIds)
|
||||
const dragged: typeof container.children = []
|
||||
const remaining: typeof container.children = []
|
||||
|
||||
for (const child of container.children) {
|
||||
if (nodeIdSet.has(child.id)) {
|
||||
dragged.push(child)
|
||||
} else {
|
||||
remaining.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
// insertionIndex was computed for the filtered (remaining) list
|
||||
const newChildren = [
|
||||
...remaining.slice(0, insertionIndex),
|
||||
...dragged,
|
||||
...remaining.slice(insertionIndex),
|
||||
]
|
||||
|
||||
store.updateNode(targetContainerId, {
|
||||
children: newChildren,
|
||||
} as Partial<import('@/types/pen').PenNode>)
|
||||
}
|
||||
} else {
|
||||
// Cross container: reparent
|
||||
const fabObjectMap = buildFabObjectMap(canvas)
|
||||
const targetBounds = rootFrameBounds.get(targetContainerId)
|
||||
|
||||
for (let i = 0; i < nodeIds.length; i++) {
|
||||
if (isLayoutTarget) {
|
||||
// Clear manual position so layout engine takes over
|
||||
useDocumentStore.getState().updateNode(nodeIds[i], {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
} as Partial<import('@/types/pen').PenNode>)
|
||||
} else if (targetBounds) {
|
||||
// Non-layout frame: convert absolute position to relative
|
||||
const fabObj = fabObjectMap.get(nodeIds[i])
|
||||
if (fabObj) {
|
||||
useDocumentStore.getState().updateNode(nodeIds[i], {
|
||||
x: (fabObj.left ?? 0) - targetBounds.x,
|
||||
y: (fabObj.top ?? 0) - targetBounds.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Reparent into the target container at successive indices
|
||||
useDocumentStore.getState().moveNode(nodeIds[i], targetContainerId, insertionIndex + i)
|
||||
}
|
||||
}
|
||||
|
||||
setFabricSyncLock(false)
|
||||
|
||||
// Force re-sync
|
||||
const doc = useDocumentStore.getState().document
|
||||
useDocumentStore.setState({
|
||||
document: { ...doc, children: [...doc.children] },
|
||||
})
|
||||
|
||||
// Clean up
|
||||
activeSession = null
|
||||
setInsertionIndicator(null)
|
||||
setContainerHighlight(null)
|
||||
canvas.requestRenderAll()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Cancel the drag-into session (safety cleanup). */
|
||||
export function cancelDragInto(): void {
|
||||
if (!activeSession) return
|
||||
|
|
|
|||
|
|
@ -68,6 +68,169 @@ function getParentBounds(parentId: string, parentNode: unknown): Bounds | null {
|
|||
return { x, y, w, h }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node (identified by absolute bounds) was dragged outside its
|
||||
* parent container. If so, reparent it to the best overlapping root frame
|
||||
* or to root level.
|
||||
*
|
||||
* Unlike `checkDragReparent` (which reads Fabric coords), this version
|
||||
* accepts pre-computed absolute bounds — needed for objects inside an
|
||||
* ActiveSelection whose Fabric left/top are group-relative.
|
||||
*
|
||||
* The caller is responsible for holding the fabric sync lock.
|
||||
* Returns true if reparenting occurred.
|
||||
*/
|
||||
export function checkDragReparentByBounds(
|
||||
nodeId: string,
|
||||
objBounds: Bounds,
|
||||
): boolean {
|
||||
const store = useDocumentStore.getState()
|
||||
const parent = store.getParentOf(nodeId)
|
||||
if (!parent) return false // Already root-level
|
||||
|
||||
const parentBounds = getParentBounds(parent.id, parent)
|
||||
if (!parentBounds) return false
|
||||
|
||||
if (!isCompletelyOutside(objBounds, parentBounds)) return false
|
||||
|
||||
// Find the root frame with the most overlap
|
||||
let bestFrameId: string | null = null
|
||||
let bestOverlap = 0
|
||||
|
||||
for (const [frameId, frameBounds] of rootFrameBounds) {
|
||||
// Don't reparent into the frame we just left
|
||||
if (frameId === parent.id) continue
|
||||
const area = overlapArea(objBounds, frameBounds)
|
||||
if (area > bestOverlap) {
|
||||
bestOverlap = area
|
||||
bestFrameId = frameId
|
||||
}
|
||||
}
|
||||
|
||||
if (bestFrameId) {
|
||||
// Reparent into the overlapping frame — convert absolute to relative position
|
||||
const targetBounds = rootFrameBounds.get(bestFrameId)!
|
||||
store.updateNode(nodeId, {
|
||||
x: objBounds.x - targetBounds.x,
|
||||
y: objBounds.y - targetBounds.y,
|
||||
})
|
||||
const targetChildren = store.getNodeById(bestFrameId)
|
||||
const childCount =
|
||||
targetChildren && 'children' in targetChildren && targetChildren.children
|
||||
? targetChildren.children.length
|
||||
: 0
|
||||
store.moveNode(nodeId, bestFrameId, childCount)
|
||||
} else {
|
||||
// No overlapping frame — make it a root-level node
|
||||
store.updateNode(nodeId, {
|
||||
x: objBounds.x,
|
||||
y: objBounds.y,
|
||||
})
|
||||
const rootCount = store.document.children.length
|
||||
store.moveNode(nodeId, null, rootCount)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a root-level node (identified by absolute bounds) should be
|
||||
* reparented INTO a root frame based on center-point containment.
|
||||
*
|
||||
* This handles the case where objects are dragged from root level into
|
||||
* a frame — the reverse of checkDragReparentByBounds (which handles
|
||||
* objects being dragged OUT of a frame).
|
||||
*
|
||||
* The caller is responsible for holding the fabric sync lock.
|
||||
* Returns true if reparenting occurred.
|
||||
*/
|
||||
export function checkReparentIntoFrame(
|
||||
nodeId: string,
|
||||
objBounds: Bounds,
|
||||
skipFrameIds?: Set<string>,
|
||||
): boolean {
|
||||
const store = useDocumentStore.getState()
|
||||
const parent = store.getParentOf(nodeId)
|
||||
if (parent) return false // Not root-level — use checkDragReparentByBounds instead
|
||||
|
||||
const centerX = objBounds.x + objBounds.w / 2
|
||||
const centerY = objBounds.y + objBounds.h / 2
|
||||
|
||||
// Find the smallest root frame containing the object's center
|
||||
let bestFrameId: string | null = null
|
||||
let bestArea = Infinity
|
||||
|
||||
for (const [frameId, frameBounds] of rootFrameBounds) {
|
||||
if (frameId === nodeId) continue
|
||||
if (skipFrameIds?.has(frameId)) continue
|
||||
if (store.isDescendantOf(frameId, nodeId)) continue
|
||||
|
||||
if (
|
||||
centerX >= frameBounds.x &&
|
||||
centerX <= frameBounds.x + frameBounds.w &&
|
||||
centerY >= frameBounds.y &&
|
||||
centerY <= frameBounds.y + frameBounds.h
|
||||
) {
|
||||
const area = frameBounds.w * frameBounds.h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestFrameId = frameId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check layout containers (nested layout frames that aren't root frames)
|
||||
for (const [containerId, info] of layoutContainerBounds) {
|
||||
if (containerId === nodeId) continue
|
||||
if (skipFrameIds?.has(containerId)) continue
|
||||
if (rootFrameBounds.has(containerId)) continue // already checked above
|
||||
if (store.isDescendantOf(containerId, nodeId)) continue
|
||||
|
||||
if (
|
||||
centerX >= info.x &&
|
||||
centerX <= info.x + info.w &&
|
||||
centerY >= info.y &&
|
||||
centerY <= info.y + info.h
|
||||
) {
|
||||
const area = info.w * info.h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestFrameId = containerId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestFrameId) return false
|
||||
|
||||
// Determine if target is a layout container (clear x/y) or plain frame (relative position)
|
||||
const layoutInfo = layoutContainerBounds.get(bestFrameId)
|
||||
if (layoutInfo) {
|
||||
// Layout container: clear position, layout engine will place it
|
||||
store.updateNode(nodeId, {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
} as any)
|
||||
} else {
|
||||
// Non-layout frame: convert absolute to relative position
|
||||
const targetBounds = rootFrameBounds.get(bestFrameId)
|
||||
if (targetBounds) {
|
||||
store.updateNode(nodeId, {
|
||||
x: objBounds.x - targetBounds.x,
|
||||
y: objBounds.y - targetBounds.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const targetChildren = store.getNodeById(bestFrameId)
|
||||
const childCount =
|
||||
targetChildren && 'children' in targetChildren && targetChildren.children
|
||||
? targetChildren.children.length
|
||||
: 0
|
||||
store.moveNode(nodeId, bestFrameId, childCount)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Fabric object was dragged outside its current parent container.
|
||||
* If so, reparent it to the overlapping root frame (if any), or to root level.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useCanvasEvents } from './use-canvas-events'
|
|||
import { useCanvasViewport } from './use-canvas-viewport'
|
||||
import { useCanvasSelection } from './use-canvas-selection'
|
||||
import { useCanvasSync } from './use-canvas-sync'
|
||||
import { useDimensionLabel } from './use-dimension-label'
|
||||
import { useFrameLabels } from './use-frame-labels'
|
||||
import { useLayoutIndicator } from './use-layout-indicator'
|
||||
import { useCanvasHover } from './use-canvas-hover'
|
||||
|
|
@ -23,7 +22,7 @@ export default function FabricCanvas() {
|
|||
useCanvasSync()
|
||||
useCanvasHover()
|
||||
useEnteredFrameOverlay()
|
||||
useDimensionLabel(containerRef)
|
||||
|
||||
useFrameLabels()
|
||||
useLayoutIndicator()
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,15 @@ export function clearGuides() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getEdges(obj: fabric.FabricObject): Edges {
|
||||
const left = obj.left ?? 0
|
||||
const top = obj.top ?? 0
|
||||
let left = obj.left ?? 0
|
||||
let top = obj.top ?? 0
|
||||
const w = (obj.width ?? 0) * (obj.scaleX ?? 1)
|
||||
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
|
||||
|
||||
// ActiveSelection / Group uses center origin — adjust to top-left
|
||||
if (obj.originX === 'center') left -= w / 2
|
||||
if (obj.originY === 'center') top -= h / 2
|
||||
|
||||
return {
|
||||
left,
|
||||
right: left + w,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ let activeDragSession: DragSession | null = null
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Recursively collect all descendant node IDs from the document tree. */
|
||||
function collectDescendantIds(nodeId: string): Set<string> {
|
||||
export function collectDescendantIds(nodeId: string): Set<string> {
|
||||
const result = new Set<string>()
|
||||
const node = useDocumentStore.getState().getNodeById(nodeId)
|
||||
if (!node) return result
|
||||
|
|
|
|||
|
|
@ -38,6 +38,20 @@ export function resolveTargetAtDepth(nodeId: string): string | null {
|
|||
// Direct match
|
||||
if (selectableIds.has(nodeId)) return nodeId
|
||||
|
||||
// Handle virtual instance child IDs (refId__childId)
|
||||
if (nodeId.includes('__')) {
|
||||
const refId = nodeId.substring(0, nodeId.indexOf('__'))
|
||||
if (selectableIds.has(refId)) return refId
|
||||
// Walk up from the RefNode
|
||||
let cur: string | undefined = refId
|
||||
while (cur) {
|
||||
const parent = useDocumentStore.getState().getParentOf(cur)
|
||||
if (!parent) break
|
||||
if (selectableIds.has(parent.id)) return parent.id
|
||||
cur = parent.id
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up parent chain
|
||||
let currentId: string | undefined = nodeId
|
||||
while (currentId) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
rotateDescendants,
|
||||
finalizeParentTransform,
|
||||
finalizeParentRotation,
|
||||
collectDescendantIds,
|
||||
} from './parent-child-transform'
|
||||
import {
|
||||
isPenToolActive,
|
||||
|
|
@ -41,12 +42,14 @@ import {
|
|||
isLayoutDragActive,
|
||||
} from './layout-reorder'
|
||||
import { isEnterableContainer, resolveTargetAtDepth } from './selection-context'
|
||||
import { checkDragReparent } from './drag-reparent'
|
||||
import { checkDragReparent, checkDragReparentByBounds, checkReparentIntoFrame } from './drag-reparent'
|
||||
import {
|
||||
checkDragIntoTarget,
|
||||
commitDragInto,
|
||||
cancelDragInto,
|
||||
isDragIntoActive,
|
||||
checkDragIntoTargetMulti,
|
||||
commitDragIntoMulti,
|
||||
} from './drag-into-layout'
|
||||
|
||||
function createNodeForTool(
|
||||
|
|
@ -452,6 +455,17 @@ export function useCanvasEvents() {
|
|||
// modification never creates a no-op undo entry.
|
||||
let preModificationDoc: PenDocument | null = null
|
||||
|
||||
// --- ActiveSelection descendant tracking ---
|
||||
// When dragging an ActiveSelection, children of selected objects are
|
||||
// separate Fabric objects (due to tree flattening) and are NOT part of
|
||||
// the selection group. We track them here so they follow the group drag.
|
||||
interface SelectionDescInfo {
|
||||
initGroupLeft: number
|
||||
initGroupTop: number
|
||||
descendants: Map<string, { obj: FabricObjectWithPenId; initLeft: number; initTop: number }>
|
||||
}
|
||||
let selectionDragInfo: SelectionDescInfo | null = null
|
||||
|
||||
// --- History batching for drag/resize/rotate ---
|
||||
let transformBatchActive = false
|
||||
let pendingBatchCloseRaf: number | null = null
|
||||
|
|
@ -475,8 +489,39 @@ export function useCanvasEvents() {
|
|||
preModificationDoc = null
|
||||
const tool = useCanvasStore.getState().activeTool
|
||||
if (tool !== 'select') return
|
||||
const target = opt.target as FabricObjectWithPenId | null
|
||||
if (!target?.penNodeId) return
|
||||
const e = opt.e as MouseEvent | undefined
|
||||
|
||||
// Keep multi-selection active when clicking one of its selected objects
|
||||
// so users can drag the whole set without needing Shift on drag start.
|
||||
if (!e?.shiftKey) {
|
||||
const { selectedIds } = useCanvasStore.getState().selection
|
||||
const clicked = opt.target as FabricObjectWithPenId | null
|
||||
const clickedResolved = clicked?.penNodeId
|
||||
? resolveTargetAtDepth(clicked.penNodeId)
|
||||
: null
|
||||
const activeObj = canvas.getActiveObject()
|
||||
const isActiveSelection = !!activeObj?.isType?.('activeSelection')
|
||||
if (
|
||||
!isActiveSelection &&
|
||||
clickedResolved &&
|
||||
selectedIds.length > 1 &&
|
||||
selectedIds.includes(clickedResolved)
|
||||
) {
|
||||
const objects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
const selectedSet = new Set(selectedIds)
|
||||
const selectedObjs = objects.filter(
|
||||
(o) => o.penNodeId && selectedSet.has(o.penNodeId),
|
||||
)
|
||||
if (selectedObjs.length > 1) {
|
||||
const sel = new fabric.ActiveSelection(selectedObjs, { canvas })
|
||||
canvas.setActiveObject(sel)
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const activeTarget = canvas.getActiveObject() ?? opt.target
|
||||
if (!activeTarget) return
|
||||
|
||||
|
||||
// Snapshot the document BEFORE any drag/resize/rotate begins.
|
||||
|
|
@ -487,6 +532,91 @@ export function useCanvasEvents() {
|
|||
.startBatch(useDocumentStore.getState().document)
|
||||
transformBatchActive = true
|
||||
|
||||
// ActiveSelection move/scale/rotate: batch + final sync in object:modified.
|
||||
// Layout/parent-child single-node logic does not apply here.
|
||||
// However, we must track descendants of selected objects so they
|
||||
// visually follow the group during drag.
|
||||
if ('getObjects' in activeTarget) {
|
||||
// Fix: if Fabric's _currentTransform targets a single object
|
||||
// inside the selection (happens when handleSelection creates
|
||||
// the ActiveSelection during selection:updated, after Fabric
|
||||
// already set up the transform for the clicked single object),
|
||||
// redirect the transform to the ActiveSelection so the whole
|
||||
// group moves/scales/rotates together.
|
||||
const ct = (canvas as unknown as { _currentTransform?: {
|
||||
target: fabric.FabricObject
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
original?: Record<string, unknown>
|
||||
} })._currentTransform
|
||||
if (ct && ct.target !== activeTarget) {
|
||||
const pointerEvt = opt.e as PointerEvent | undefined
|
||||
if (pointerEvt) {
|
||||
canvas.calcOffset()
|
||||
const pointer = canvas.getScenePoint(pointerEvt)
|
||||
ct.target = activeTarget
|
||||
ct.offsetX = pointer.x - (activeTarget.left ?? 0)
|
||||
ct.offsetY = pointer.y - (activeTarget.top ?? 0)
|
||||
if (ct.original) {
|
||||
ct.original = {
|
||||
...ct.original,
|
||||
left: activeTarget.left,
|
||||
top: activeTarget.top,
|
||||
scaleX: activeTarget.scaleX,
|
||||
scaleY: activeTarget.scaleY,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cancelLayoutDrag()
|
||||
|
||||
const group = activeTarget as fabric.ActiveSelection
|
||||
const selObjs = group.getObjects() as FabricObjectWithPenId[]
|
||||
const selIds = new Set(
|
||||
selObjs.map((o) => o.penNodeId).filter(Boolean) as string[],
|
||||
)
|
||||
|
||||
// Collect descendants of all selected objects that are NOT in the selection
|
||||
const allCanvasObjs = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
const canvasObjMap = new Map(
|
||||
allCanvasObjs.filter((o) => o.penNodeId).map((o) => [o.penNodeId!, o]),
|
||||
)
|
||||
const descendants = new Map<
|
||||
string,
|
||||
{ obj: FabricObjectWithPenId; initLeft: number; initTop: number }
|
||||
>()
|
||||
|
||||
for (const selObj of selObjs) {
|
||||
if (!selObj.penNodeId) continue
|
||||
for (const descId of collectDescendantIds(selObj.penNodeId)) {
|
||||
if (selIds.has(descId) || descendants.has(descId)) continue
|
||||
const descObj = canvasObjMap.get(descId)
|
||||
if (descObj) {
|
||||
descendants.set(descId, {
|
||||
obj: descObj,
|
||||
initLeft: descObj.left ?? 0,
|
||||
initTop: descObj.top ?? 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectionDragInfo =
|
||||
descendants.size > 0
|
||||
? {
|
||||
initGroupLeft: group.left ?? 0,
|
||||
initGroupTop: group.top ?? 0,
|
||||
descendants,
|
||||
}
|
||||
: null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const target = activeTarget as FabricObjectWithPenId
|
||||
if (!target.penNodeId) return
|
||||
|
||||
// Only start layout reorder for actual move drags.
|
||||
// Scale/rotate handles on layout children should follow normal transform sync.
|
||||
const transform = (opt as unknown as {
|
||||
|
|
@ -514,6 +644,7 @@ export function useCanvasEvents() {
|
|||
// commit and cleanup. In Fabric.js v7 mouse:up can fire before
|
||||
// object:modified, which would clear the session prematurely.
|
||||
endParentDrag()
|
||||
selectionDragInfo = null
|
||||
|
||||
// Defer batch close one frame so object:modified can run first.
|
||||
if (transformBatchActive) {
|
||||
|
|
@ -558,14 +689,25 @@ export function useCanvasEvents() {
|
|||
setFabricSyncLock(false)
|
||||
}
|
||||
|
||||
/** Absolute bounds for each child computed during syncSelectionToStore. */
|
||||
interface ChildAbsBounds {
|
||||
nodeId: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the absolute position of each child inside an ActiveSelection
|
||||
* and sync every child to the document store.
|
||||
* Returns an array of absolute bounds (for subsequent reparent checks).
|
||||
*/
|
||||
const syncSelectionToStore = (
|
||||
target: fabric.FabricObject,
|
||||
) => {
|
||||
if (!('getObjects' in target)) return
|
||||
): ChildAbsBounds[] => {
|
||||
const boundsOut: ChildAbsBounds[] = []
|
||||
if (!('getObjects' in target)) return boundsOut
|
||||
const group = target as fabric.ActiveSelection
|
||||
const groupMatrix = group.calcTransformMatrix()
|
||||
|
||||
|
|
@ -600,23 +742,32 @@ export function useCanvasEvents() {
|
|||
const scaleX = child.scaleX ?? 1
|
||||
const scaleY = child.scaleY ?? 1
|
||||
|
||||
const absW = (child.width ?? 0) * scaleX
|
||||
const absH = (child.height ?? 0) * scaleY
|
||||
|
||||
const updates: Partial<PenNode> = {
|
||||
x: absLeft - offsetX,
|
||||
y: absTop - offsetY,
|
||||
rotation: child.angle ?? 0,
|
||||
}
|
||||
if (child.width !== undefined) {
|
||||
;(updates as Record<string, unknown>).width =
|
||||
child.width * scaleX
|
||||
;(updates as Record<string, unknown>).width = absW
|
||||
}
|
||||
if (child.height !== undefined) {
|
||||
;(updates as Record<string, unknown>).height =
|
||||
child.height * scaleY
|
||||
;(updates as Record<string, unknown>).height = absH
|
||||
}
|
||||
|
||||
useDocumentStore.getState().updateNode(obj.penNodeId, updates)
|
||||
boundsOut.push({
|
||||
nodeId: obj.penNodeId,
|
||||
x: absLeft,
|
||||
y: absTop,
|
||||
w: absW,
|
||||
h: absH,
|
||||
})
|
||||
}
|
||||
setFabricSyncLock(false)
|
||||
return boundsOut
|
||||
}
|
||||
|
||||
// Real-time sync during drag / resize / rotate (locked to prevent circular sync)
|
||||
|
|
@ -629,6 +780,12 @@ export function useCanvasEvents() {
|
|||
clipPathsCleared = true
|
||||
const movingObj = opt.target as FabricObjectWithPenId
|
||||
if (movingObj.clipPath) movingObj.clipPath = undefined
|
||||
// Clear clip paths on objects INSIDE the ActiveSelection
|
||||
if ('getObjects' in opt.target) {
|
||||
for (const child of (opt.target as fabric.ActiveSelection).getObjects()) {
|
||||
if (child.clipPath) child.clipPath = undefined
|
||||
}
|
||||
}
|
||||
// Also clear descendants' clip paths
|
||||
const session = getActiveDragSession()
|
||||
if (session) {
|
||||
|
|
@ -636,6 +793,46 @@ export function useCanvasEvents() {
|
|||
if (descObj.clipPath) descObj.clipPath = undefined
|
||||
}
|
||||
}
|
||||
// Clear clip paths on ActiveSelection descendants too
|
||||
if (selectionDragInfo) {
|
||||
for (const [, { obj }] of selectionDragInfo.descendants) {
|
||||
if (obj.clipPath) obj.clipPath = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSelection drag: snap + move descendants + drag-into detection
|
||||
if ('getObjects' in opt.target) {
|
||||
const group = opt.target as fabric.ActiveSelection
|
||||
|
||||
// Smart guides + snapping for the whole selection bounding box
|
||||
calculateAndSnap(opt.target, canvas)
|
||||
|
||||
// Move descendants based on the (possibly snapped) group position
|
||||
if (selectionDragInfo) {
|
||||
const deltaX = (group.left ?? 0) - selectionDragInfo.initGroupLeft
|
||||
const deltaY = (group.top ?? 0) - selectionDragInfo.initGroupTop
|
||||
for (const [, { obj, initLeft, initTop }] of selectionDragInfo.descendants) {
|
||||
obj.set({ left: initLeft + deltaX, top: initTop + deltaY })
|
||||
obj.setCoords()
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-into layout container detection (using selection center)
|
||||
const selObjs = group.getObjects() as FabricObjectWithPenId[]
|
||||
const selNodeIds = selObjs
|
||||
.map((o) => o.penNodeId)
|
||||
.filter(Boolean) as string[]
|
||||
// ActiveSelection uses center origin — left/top IS the center
|
||||
const cx = group.originX === 'center'
|
||||
? (group.left ?? 0)
|
||||
: (group.left ?? 0) + ((group.width ?? 0) * (group.scaleX ?? 1)) / 2
|
||||
const cy = group.originY === 'center'
|
||||
? (group.top ?? 0)
|
||||
: (group.top ?? 0) + ((group.height ?? 0) * (group.scaleY ?? 1)) / 2
|
||||
checkDragIntoTargetMulti(cx, cy, selNodeIds, canvas)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isLayoutDragActive()) {
|
||||
|
|
@ -676,7 +873,14 @@ export function useCanvasEvents() {
|
|||
// click-to-select without modification never creates a no-op undo
|
||||
// entry. We use the pre-modification snapshot captured in mouse:down
|
||||
// as the batch base to guarantee a correct undo point.
|
||||
let inModifiedHandler = false
|
||||
canvas.on('object:modified', (opt) => {
|
||||
// Guard against re-entry: discardActiveObject() fires
|
||||
// _finalizeCurrentTransform → object:modified recursively.
|
||||
if (inModifiedHandler) return
|
||||
inModifiedHandler = true
|
||||
try {
|
||||
|
||||
if (pendingBatchCloseRaf !== null) {
|
||||
cancelAnimationFrame(pendingBatchCloseRaf)
|
||||
pendingBatchCloseRaf = null
|
||||
|
|
@ -697,6 +901,9 @@ export function useCanvasEvents() {
|
|||
useHistoryStore.getState().startBatch(baseDoc)
|
||||
}
|
||||
|
||||
let isSelectionModification = false
|
||||
let selectionReparented = false
|
||||
|
||||
try {
|
||||
// Single object -- bake scale and sync
|
||||
const asPen = target as FabricObjectWithPenId
|
||||
|
|
@ -757,7 +964,22 @@ export function useCanvasEvents() {
|
|||
}
|
||||
|
||||
// Check if the node was dragged out of / into a root frame
|
||||
if (checkDragReparent(asPen)) {
|
||||
const didReparentOut = checkDragReparent(asPen)
|
||||
// Fallback: check if a root-level node should be reparented INTO a frame
|
||||
const didReparentIn = !didReparentOut && asPen.penNodeId
|
||||
? (() => {
|
||||
setFabricSyncLock(true)
|
||||
const result = checkReparentIntoFrame(asPen.penNodeId!, {
|
||||
x: asPen.left ?? 0,
|
||||
y: asPen.top ?? 0,
|
||||
w: (asPen.width ?? 0) * (asPen.scaleX ?? 1),
|
||||
h: (asPen.height ?? 0) * (asPen.scaleY ?? 1),
|
||||
})
|
||||
setFabricSyncLock(false)
|
||||
return result
|
||||
})()
|
||||
: false
|
||||
if (didReparentOut || didReparentIn) {
|
||||
// Force re-sync since tree structure changed
|
||||
rebuildNodeRenderInfo()
|
||||
const doc = useDocumentStore.getState().document
|
||||
|
|
@ -766,6 +988,7 @@ export function useCanvasEvents() {
|
|||
})
|
||||
}
|
||||
} else if ('getObjects' in target) {
|
||||
isSelectionModification = true
|
||||
// ActiveSelection -- bake scale per child, then sync all
|
||||
const group = target as fabric.ActiveSelection
|
||||
for (const child of group.getObjects()) {
|
||||
|
|
@ -784,7 +1007,42 @@ export function useCanvasEvents() {
|
|||
}
|
||||
child.setCoords()
|
||||
}
|
||||
syncSelectionToStore(target)
|
||||
// Drag-into container: reparent all selected objects
|
||||
if (isDragIntoActive()) {
|
||||
canvas.discardActiveObject()
|
||||
commitDragIntoMulti(canvas)
|
||||
rebuildNodeRenderInfo()
|
||||
closeTransformBatch()
|
||||
return
|
||||
}
|
||||
|
||||
const childBounds = syncSelectionToStore(target)
|
||||
|
||||
// Check reparenting for each child based on final positions:
|
||||
// 1. Objects with a parent that are dragged completely outside → detach
|
||||
// 2. Root-level objects whose center is inside a frame → reparent into it
|
||||
let anyReparented = false
|
||||
const selNodeIdSet = new Set(childBounds.map((b) => b.nodeId))
|
||||
setFabricSyncLock(true)
|
||||
for (const b of childBounds) {
|
||||
const bounds = { x: b.x, y: b.y, w: b.w, h: b.h }
|
||||
if (checkDragReparentByBounds(b.nodeId, bounds)) {
|
||||
anyReparented = true
|
||||
} else if (
|
||||
checkReparentIntoFrame(b.nodeId, bounds, selNodeIdSet)
|
||||
) {
|
||||
anyReparented = true
|
||||
}
|
||||
}
|
||||
setFabricSyncLock(false)
|
||||
|
||||
if (anyReparented) {
|
||||
// Tree structure changed — force full re-sync after the
|
||||
// ActiveSelection is disbanded so clip paths and positions
|
||||
// are recomputed correctly.
|
||||
isSelectionModification = false
|
||||
selectionReparented = true
|
||||
}
|
||||
}
|
||||
|
||||
// Safety cleanup: clear any leftover drag-into session that wasn't
|
||||
|
|
@ -800,13 +1058,39 @@ export function useCanvasEvents() {
|
|||
// Force re-sync so clip paths (which use absolute coordinates) are
|
||||
// recomputed from the new node positions. Without this, children of
|
||||
// a dragged frame stay clipped to the old parent frame bounds.
|
||||
// Skip the forced setState for ActiveSelection modifications — the
|
||||
// positions were already written by syncSelectionToStore(), and
|
||||
// re-syncing absolute coords onto group-relative objects would undo
|
||||
// the move.
|
||||
rebuildNodeRenderInfo()
|
||||
const currentDoc = useDocumentStore.getState().document
|
||||
useDocumentStore.setState({
|
||||
document: { ...currentDoc, children: [...currentDoc.children] },
|
||||
})
|
||||
if (selectionReparented) {
|
||||
// Disband the ActiveSelection so objects become individual canvas
|
||||
// items. Then defer the full re-sync to the next frame — Fabric
|
||||
// needs a render cycle to fully restore the objects' transforms
|
||||
// and properties from the disbanded group. Without this delay,
|
||||
// the subscriber re-syncs while objects are in a transitional
|
||||
// state and visual properties (fill, clip paths) get lost.
|
||||
canvas.discardActiveObject()
|
||||
canvas.requestRenderAll()
|
||||
requestAnimationFrame(() => {
|
||||
rebuildNodeRenderInfo()
|
||||
const doc = useDocumentStore.getState().document
|
||||
useDocumentStore.setState({
|
||||
document: { ...doc, children: [...doc.children] },
|
||||
})
|
||||
})
|
||||
} else if (!isSelectionModification) {
|
||||
const currentDoc = useDocumentStore.getState().document
|
||||
useDocumentStore.setState({
|
||||
document: { ...currentDoc, children: [...currentDoc.children] },
|
||||
})
|
||||
}
|
||||
|
||||
closeTransformBatch()
|
||||
|
||||
} finally {
|
||||
inModifiedHandler = false
|
||||
}
|
||||
})
|
||||
|
||||
// --- Text editing: sync edited content back to document store ---
|
||||
|
|
|
|||
|
|
@ -1,12 +1,29 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
import { resolveTargetAtDepth, getChildIds } from './selection-context'
|
||||
import { COMPONENT_COLOR, INSTANCE_COLOR } from './canvas-constants'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
|
||||
const HOVER_COLOR = '#3b82f6'
|
||||
const HOVER_LINE_WIDTH = 1.5
|
||||
const CHILD_DASH = [4, 4]
|
||||
|
||||
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if ('reusable' in node && node.reusable === true) result.add(node.id)
|
||||
if ('children' in node && node.children) collectReusableIds(node.children, result)
|
||||
}
|
||||
}
|
||||
|
||||
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'ref') result.add(node.id)
|
||||
if ('children' in node && node.children) collectInstanceIds(node.children, result)
|
||||
}
|
||||
}
|
||||
|
||||
export function useCanvasHover() {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -66,6 +83,12 @@ export function useCanvasHover() {
|
|||
const zoom = vpt[0]
|
||||
const dpr = el.width / el.offsetWidth
|
||||
|
||||
const docChildren = useDocumentStore.getState().document.children
|
||||
const reusableIds = new Set<string>()
|
||||
const instanceIds = new Set<string>()
|
||||
collectReusableIds(docChildren, reusableIds)
|
||||
collectInstanceIds(docChildren, instanceIds)
|
||||
|
||||
ctx.save()
|
||||
ctx.setTransform(
|
||||
vpt[0] * dpr, vpt[1] * dpr,
|
||||
|
|
@ -76,13 +99,13 @@ export function useCanvasHover() {
|
|||
// Solid outline on hovered target — skip if already selected
|
||||
// (Fabric draws its own selection handles)
|
||||
if (!isSelected) {
|
||||
drawNodeOutline(ctx, hoveredId, false, zoom)
|
||||
drawNodeOutline(ctx, hoveredId, false, zoom, reusableIds, instanceIds)
|
||||
}
|
||||
|
||||
// Dashed outlines on direct children — always draw on hover
|
||||
const childIds = getChildIds(hoveredId)
|
||||
for (const childId of childIds) {
|
||||
drawNodeOutline(ctx, childId, true, zoom)
|
||||
drawNodeOutline(ctx, childId, true, zoom, reusableIds, instanceIds)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
|
|
@ -93,6 +116,8 @@ export function useCanvasHover() {
|
|||
nodeId: string,
|
||||
dashed: boolean,
|
||||
zoom: number,
|
||||
reusableIds: Set<string>,
|
||||
instanceIds: Set<string>,
|
||||
) {
|
||||
const objects = canvas!.getObjects() as FabricObjectWithPenId[]
|
||||
const obj = objects.find((o) => o.penNodeId === nodeId)
|
||||
|
|
@ -104,6 +129,9 @@ export function useCanvasHover() {
|
|||
const h = (obj.height ?? 0) * (obj.scaleY ?? 1)
|
||||
const angle = obj.angle ?? 0
|
||||
|
||||
const isReusable = reusableIds.has(nodeId)
|
||||
const isInstance = instanceIds.has(nodeId)
|
||||
|
||||
ctx.save()
|
||||
|
||||
if (angle !== 0) {
|
||||
|
|
@ -114,9 +142,15 @@ export function useCanvasHover() {
|
|||
ctx.translate(-cx, -cy)
|
||||
}
|
||||
|
||||
ctx.strokeStyle = HOVER_COLOR
|
||||
ctx.strokeStyle = isReusable
|
||||
? COMPONENT_COLOR
|
||||
: isInstance
|
||||
? INSTANCE_COLOR
|
||||
: HOVER_COLOR
|
||||
ctx.lineWidth = HOVER_LINE_WIDTH / zoom
|
||||
if (dashed) {
|
||||
if (isInstance) {
|
||||
ctx.setLineDash(CHILD_DASH.map((d) => d / zoom))
|
||||
} else if (dashed) {
|
||||
ctx.setLineDash(CHILD_DASH.map((d) => d / zoom))
|
||||
} else {
|
||||
ctx.setLineDash([])
|
||||
|
|
|
|||
|
|
@ -5,6 +5,16 @@ import { useCanvasStore } from '@/stores/canvas-store'
|
|||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
import { resolveTargetAtDepth } from './selection-context'
|
||||
|
||||
/**
|
||||
* When true, the next selection event will skip depth-resolution and
|
||||
* pass through as-is. Used by the layer panel to programmatically select
|
||||
* children without the handler resolving them back to their parent.
|
||||
*/
|
||||
let skipNextDepthResolve = false
|
||||
export function setSkipNextDepthResolve() {
|
||||
skipNextDepthResolve = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a list of Fabric selected objects to node IDs at the current
|
||||
* entered-frame depth. If any target falls outside the current context,
|
||||
|
|
@ -44,9 +54,59 @@ export function useCanvasSelection() {
|
|||
if (!canvas) return
|
||||
clearInterval(interval)
|
||||
|
||||
const handleSelection = (e: { selected?: FabricObject[] }) => {
|
||||
const selected = e.selected ?? []
|
||||
const ids = resolveIds(selected)
|
||||
// Guard against re-entry: setActiveObject fires selection events
|
||||
// synchronously, which would call handleSelection again and cause
|
||||
// infinite recursion.
|
||||
let updatingSelection = false
|
||||
|
||||
const handleSelection = (e: { selected?: FabricObject[]; e?: unknown }) => {
|
||||
if (updatingSelection) return
|
||||
|
||||
// Programmatic selection from layer panel — skip depth resolution
|
||||
if (skipNextDepthResolve) {
|
||||
skipNextDepthResolve = false
|
||||
return
|
||||
}
|
||||
|
||||
// `selection:updated` payload `selected` may contain only delta objects.
|
||||
// Always read the full active selection from canvas for accurate multi-select.
|
||||
const selected = canvas.getActiveObjects()
|
||||
const fallbackSelected = e.selected ?? []
|
||||
const effectiveSelected = selected.length > 0 ? selected : fallbackSelected
|
||||
const prevIds = useCanvasStore.getState().selection.selectedIds
|
||||
const mouseEvent = e.e as MouseEvent | undefined
|
||||
|
||||
// If user already has a multi-selection and clicks one selected object
|
||||
// (without Shift), keep the whole selection so dragging moves all.
|
||||
if (!mouseEvent?.shiftKey && prevIds.length > 1 && effectiveSelected.length === 1) {
|
||||
const clicked = effectiveSelected[0] as FabricObjectWithPenId
|
||||
const resolvedClicked = clicked?.penNodeId
|
||||
? resolveTargetAtDepth(clicked.penNodeId)
|
||||
: null
|
||||
|
||||
if (resolvedClicked && prevIds.includes(resolvedClicked)) {
|
||||
const objects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
const prevSet = new Set(prevIds)
|
||||
const restoredObjects = objects.filter(
|
||||
(o) => o.penNodeId && prevSet.has(o.penNodeId),
|
||||
)
|
||||
|
||||
if (restoredObjects.length > 1) {
|
||||
updatingSelection = true
|
||||
try {
|
||||
const restored = new ActiveSelection(restoredObjects, { canvas })
|
||||
canvas.setActiveObject(restored)
|
||||
canvas.requestRenderAll()
|
||||
useCanvasStore.getState().setSelection(prevIds, prevIds[0] ?? null)
|
||||
} finally {
|
||||
updatingSelection = false
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ids = resolveIds(effectiveSelected)
|
||||
useCanvasStore.getState().setSelection(ids, ids[0] ?? null)
|
||||
|
||||
// Correct Fabric's active object to match the depth-resolved target.
|
||||
|
|
@ -56,29 +116,34 @@ export function useCanvasSelection() {
|
|||
|
||||
const objects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
|
||||
if (ids.length === 1) {
|
||||
const currentActive = selected[0] as FabricObjectWithPenId
|
||||
if (currentActive?.penNodeId !== ids[0]) {
|
||||
const correctObj = objects.find((o) => o.penNodeId === ids[0])
|
||||
if (correctObj) {
|
||||
canvas.setActiveObject(correctObj)
|
||||
updatingSelection = true
|
||||
try {
|
||||
if (ids.length === 1) {
|
||||
const currentActive = effectiveSelected[0] as FabricObjectWithPenId
|
||||
if (currentActive?.penNodeId !== ids[0]) {
|
||||
const correctObj = objects.find((o) => o.penNodeId === ids[0])
|
||||
if (correctObj) {
|
||||
canvas.setActiveObject(correctObj)
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-select: build an ActiveSelection from the resolved objects
|
||||
const resolvedSet = new Set(ids)
|
||||
const resolvedObjs = objects.filter(
|
||||
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
|
||||
)
|
||||
if (resolvedObjs.length > 1) {
|
||||
const sel = new ActiveSelection(resolvedObjs, { canvas })
|
||||
canvas.setActiveObject(sel)
|
||||
canvas.requestRenderAll()
|
||||
} else if (resolvedObjs.length === 1) {
|
||||
canvas.setActiveObject(resolvedObjs[0])
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-select: build an ActiveSelection from the resolved objects
|
||||
const resolvedSet = new Set(ids)
|
||||
const resolvedObjs = objects.filter(
|
||||
(o) => o.penNodeId && resolvedSet.has(o.penNodeId),
|
||||
)
|
||||
if (resolvedObjs.length > 1) {
|
||||
const sel = new ActiveSelection(resolvedObjs, { canvas })
|
||||
canvas.setActiveObject(sel)
|
||||
canvas.requestRenderAll()
|
||||
} else if (resolvedObjs.length === 1) {
|
||||
canvas.setActiveObject(resolvedObjs[0])
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
} finally {
|
||||
updatingSelection = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { syncFabricObject } from './canvas-object-sync'
|
|||
import { isFabricSyncLocked, setFabricSyncLock } from './canvas-sync-lock'
|
||||
import { pendingAnimationNodes, getNextStaggerDelay } from '@/services/ai/design-animation'
|
||||
import { resolveNodeForCanvas, getDefaultTheme } from '@/variables/resolve-variables'
|
||||
import { findNodeInTree } from '@/stores/document-store'
|
||||
import { COMPONENT_COLOR, INSTANCE_COLOR, SELECTION_BLUE } from './canvas-constants'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clip info — tracks parent frame bounds for child clipping
|
||||
|
|
@ -116,17 +118,20 @@ function fitContentWidth(node: PenNode): number {
|
|||
}
|
||||
|
||||
/** Compute fit-content height from children. */
|
||||
function fitContentHeight(node: PenNode): number {
|
||||
function fitContentHeight(node: PenNode, parentAvailW?: number): number {
|
||||
if (!('children' in node) || !node.children?.length) return 0
|
||||
const layout = 'layout' in node ? (node as ContainerProps).layout : undefined
|
||||
const pad = resolvePadding('padding' in node ? (node as any).padding : undefined)
|
||||
const gap = 'gap' in node && typeof (node as any).gap === 'number' ? (node as any).gap : 0
|
||||
// Compute available width for children (used by text height estimation)
|
||||
const nodeW = getNodeWidth(node, parentAvailW)
|
||||
const childAvailW = nodeW > 0 ? Math.max(0, nodeW - pad.left - pad.right) : parentAvailW
|
||||
if (layout === 'vertical') {
|
||||
const childTotal = node.children.reduce((sum, c) => sum + getNodeHeight(c), 0)
|
||||
const childTotal = node.children.reduce((sum, c) => sum + getNodeHeight(c, undefined, childAvailW), 0)
|
||||
const gapTotal = gap * Math.max(0, node.children.length - 1)
|
||||
return childTotal + gapTotal + pad.top + pad.bottom
|
||||
}
|
||||
const maxChildH = node.children.reduce((max, c) => Math.max(max, getNodeHeight(c)), 0)
|
||||
const maxChildH = node.children.reduce((max, c) => Math.max(max, getNodeHeight(c, undefined, childAvailW)), 0)
|
||||
return maxChildH + pad.top + pad.bottom
|
||||
}
|
||||
|
||||
|
|
@ -156,29 +161,71 @@ function getNodeWidth(node: PenNode, parentAvail?: number): number {
|
|||
return 0
|
||||
}
|
||||
|
||||
function getNodeHeight(node: PenNode, parentAvail?: number): number {
|
||||
function getNodeHeight(node: PenNode, parentAvail?: number, parentAvailW?: number): number {
|
||||
if ('height' in node) {
|
||||
const s = parseSizing(node.height)
|
||||
if (typeof s === 'number' && s > 0) return s
|
||||
if (s === 'fill' && parentAvail) return parentAvail
|
||||
if (s === 'fit') {
|
||||
const fit = fitContentHeight(node)
|
||||
const fit = fitContentHeight(node, parentAvailW)
|
||||
if (fit > 0) return fit
|
||||
}
|
||||
}
|
||||
// Containers without explicit height: compute from children
|
||||
if ('children' in node && node.children?.length) {
|
||||
const fit = fitContentHeight(node)
|
||||
const fit = fitContentHeight(node, parentAvailW)
|
||||
if (fit > 0) return fit
|
||||
}
|
||||
if (node.type === 'text') {
|
||||
const fontSize = node.fontSize ?? 16
|
||||
const lineHeight = ('lineHeight' in node ? node.lineHeight : undefined) ?? 1.2
|
||||
return fontSize * lineHeight
|
||||
return estimateTextHeight(node, parentAvailW)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Estimate text height including multi-line wrapping when available width is known. */
|
||||
function estimateTextHeight(node: PenNode, availableWidth?: number): number {
|
||||
// Access text-specific properties via Record to avoid union type issues
|
||||
const n = node as unknown as Record<string, unknown>
|
||||
const fontSize = (typeof n.fontSize === 'number' ? n.fontSize : 16)
|
||||
const lineHeight = (typeof n.lineHeight === 'number' ? n.lineHeight : 1.2)
|
||||
const singleLineH = fontSize * lineHeight
|
||||
|
||||
// Get text content
|
||||
const rawContent = n.content
|
||||
const content = typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent.map((s: { text: string }) => s.text).join('')
|
||||
: ''
|
||||
if (!content) return singleLineH
|
||||
|
||||
// Determine the effective text width for wrapping estimation
|
||||
let textWidth = 0
|
||||
if ('width' in node) {
|
||||
const w = parseSizing(node.width)
|
||||
if (typeof w === 'number' && w > 0) textWidth = w
|
||||
else if (w === 'fill' && availableWidth && availableWidth > 0) textWidth = availableWidth
|
||||
}
|
||||
|
||||
// If no width constraint is known, return single-line height
|
||||
if (textWidth <= 0) return singleLineH
|
||||
|
||||
// Estimate wrapped lines using per-character width estimation
|
||||
// CJK characters are roughly 1em wide; Latin characters ~0.55em
|
||||
let totalTextWidth = 0
|
||||
for (const ch of content) {
|
||||
const code = ch.codePointAt(0) ?? 0
|
||||
const isCjk = (code >= 0x4E00 && code <= 0x9FFF)
|
||||
|| (code >= 0x3000 && code <= 0x30FF)
|
||||
|| (code >= 0xFF00 && code <= 0xFFEF)
|
||||
|| (code >= 0xAC00 && code <= 0xD7AF)
|
||||
totalTextWidth += isCjk ? fontSize : fontSize * 0.55
|
||||
}
|
||||
|
||||
const lines = Math.max(1, Math.ceil(totalTextWidth / textWidth))
|
||||
return Math.round(lines * singleLineH)
|
||||
}
|
||||
|
||||
/** Compute child positions according to the parent's layout rules. */
|
||||
function computeLayoutPositions(
|
||||
parent: PenNode,
|
||||
|
|
@ -211,7 +258,7 @@ function computeLayoutPositions(
|
|||
const s = parseSizing((ch as any)[prop])
|
||||
if (s === 'fill') return 'fill' as const
|
||||
}
|
||||
return isVertical ? getNodeHeight(ch, availH) : getNodeWidth(ch, availW)
|
||||
return isVertical ? getNodeHeight(ch, availH, availW) : getNodeWidth(ch, availW)
|
||||
})
|
||||
const fixedTotal = mainSizing.reduce<number>(
|
||||
(sum, s) => sum + (typeof s === 'number' ? s : 0),
|
||||
|
|
@ -225,7 +272,7 @@ function computeLayoutPositions(
|
|||
const mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
|
||||
return {
|
||||
w: isVertical ? getNodeWidth(ch, availW) : mainSize,
|
||||
h: isVertical ? mainSize : getNodeHeight(ch, availH),
|
||||
h: isVertical ? mainSize : getNodeHeight(ch, availH, availW),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -317,6 +364,92 @@ function computeLayoutPositions(
|
|||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolve RefNodes — expand instances by looking up their referenced component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Give children unique IDs scoped to the instance, apply overrides from descendants. */
|
||||
function remapInstanceChildIds(
|
||||
children: PenNode[],
|
||||
refId: string,
|
||||
overrides?: Record<string, Partial<PenNode>>,
|
||||
): PenNode[] {
|
||||
return children.map((child) => {
|
||||
const virtualId = `${refId}__${child.id}`
|
||||
const ov = overrides?.[child.id] ?? {}
|
||||
const mapped = { ...child, ...ov, id: virtualId } as PenNode
|
||||
if ('children' in mapped && mapped.children) {
|
||||
;(mapped as PenNode & { children: PenNode[] }).children =
|
||||
remapInstanceChildIds(mapped.children, refId, overrides)
|
||||
}
|
||||
return mapped
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively resolve all RefNodes in the tree by expanding them
|
||||
* with their referenced component's structure.
|
||||
*/
|
||||
function resolveRefs(
|
||||
nodes: PenNode[],
|
||||
rootNodes: PenNode[],
|
||||
visited = new Set<string>(),
|
||||
): PenNode[] {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.type !== 'ref') {
|
||||
if ('children' in node && node.children) {
|
||||
return [
|
||||
{
|
||||
...node,
|
||||
children: resolveRefs(node.children, rootNodes, visited),
|
||||
} as PenNode,
|
||||
]
|
||||
}
|
||||
return [node]
|
||||
}
|
||||
|
||||
// Resolve RefNode
|
||||
if (visited.has(node.ref)) return [] // circular reference guard
|
||||
const component = findNodeInTree(rootNodes, node.ref)
|
||||
if (!component) return []
|
||||
|
||||
visited.add(node.ref)
|
||||
|
||||
const refNode = node as PenNode & { descendants?: Record<string, Partial<PenNode>> }
|
||||
// Apply top-level visual overrides from descendants[componentId]
|
||||
const topOverrides = refNode.descendants?.[node.ref] ?? {}
|
||||
|
||||
// Build resolved node: component base → overrides → RefNode's own properties
|
||||
const resolved: Record<string, unknown> = { ...component, ...topOverrides }
|
||||
// Apply all explicitly-defined RefNode properties (position, size, opacity, etc.)
|
||||
for (const [key, val] of Object.entries(node)) {
|
||||
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
|
||||
if (val !== undefined) {
|
||||
resolved[key] = val
|
||||
}
|
||||
}
|
||||
// Use component's type (not 'ref') and ensure name fallback
|
||||
resolved.type = component.type
|
||||
if (!resolved.name) resolved.name = component.name
|
||||
// Clear the reusable flag — this is an instance, not the component
|
||||
delete resolved.reusable
|
||||
const resolvedNode = resolved as unknown as PenNode
|
||||
|
||||
// Remap children IDs to avoid clashes with the original component
|
||||
if ('children' in resolvedNode && resolvedNode.children) {
|
||||
;(resolvedNode as PenNode & { children: PenNode[] }).children =
|
||||
remapInstanceChildIds(
|
||||
resolvedNode.children,
|
||||
node.id,
|
||||
refNode.descendants,
|
||||
)
|
||||
}
|
||||
|
||||
visited.delete(node.ref)
|
||||
return [resolvedNode]
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flatten document tree → absolute-positioned list for Fabric.js
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -370,7 +503,7 @@ function flattenNodes(
|
|||
r.height = parentAvailH
|
||||
changed = true
|
||||
} else if (s === 'fit') {
|
||||
r.height = getNodeHeight(node, parentAvailH)
|
||||
r.height = getNodeHeight(node, parentAvailH, parentAvailW)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
|
@ -421,7 +554,8 @@ function flattenNodes(
|
|||
let childClip = clipCtx
|
||||
const cr = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
|
||||
const isRootFrame = node.type === 'frame' && depth === 0
|
||||
if (isRootFrame || cr > 0) {
|
||||
const hasClipContent = 'clipContent' in node && (node as ContainerProps).clipContent === true
|
||||
if (isRootFrame || cr > 0 || hasClipContent) {
|
||||
childClip = { x: parentAbsX, y: parentAbsY, w: nodeW, h: nodeH, rx: cr }
|
||||
}
|
||||
|
||||
|
|
@ -461,7 +595,8 @@ export function rebuildNodeRenderInfo() {
|
|||
nodeRenderInfo.clear()
|
||||
rootFrameBounds.clear()
|
||||
layoutContainerBounds.clear()
|
||||
flattenNodes(state.document.children, 0, 0, undefined, undefined, undefined, new Map())
|
||||
const resolvedTree = resolveRefs(state.document.children, state.document.children)
|
||||
flattenNodes(resolvedTree, 0, 0, undefined, undefined, undefined, new Map())
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -554,8 +689,10 @@ export function useCanvasSync() {
|
|||
nodeRenderInfo.clear()
|
||||
rootFrameBounds.clear()
|
||||
layoutContainerBounds.clear()
|
||||
// Resolve RefNodes before flattening so instances render as their component
|
||||
const resolvedTree = resolveRefs(state.document.children, state.document.children)
|
||||
const flatNodes = flattenNodes(
|
||||
state.document.children, 0, 0, undefined, undefined, undefined, clipMap,
|
||||
resolvedTree, 0, 0, undefined, undefined, undefined, clipMap,
|
||||
).map((node) => resolveNodeForCanvas(node, variables, activeTheme))
|
||||
const nodeMap = new Map(flatNodes.map((n) => [n.id, n]))
|
||||
const objects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
|
|
@ -565,6 +702,17 @@ export function useCanvasSync() {
|
|||
.map((o) => [o.penNodeId!, o]),
|
||||
)
|
||||
|
||||
// Collect component and instance IDs for selection styling
|
||||
const reusableIds = new Set<string>()
|
||||
const instanceIds = new Set<string>()
|
||||
;(function collectComponentIds(nodes: PenNode[]) {
|
||||
for (const n of nodes) {
|
||||
if ('reusable' in n && n.reusable === true) reusableIds.add(n.id)
|
||||
if (n.type === 'ref') instanceIds.add(n.id)
|
||||
if ('children' in n && n.children) collectComponentIds(n.children)
|
||||
}
|
||||
})(state.document.children)
|
||||
|
||||
// Remove objects that no longer exist in the document
|
||||
for (const obj of objects) {
|
||||
if (obj.penNodeId && !nodeMap.has(obj.penNodeId)) {
|
||||
|
|
@ -577,8 +725,30 @@ export function useCanvasSync() {
|
|||
if (node.type === 'ref') continue // Skip unresolved refs
|
||||
|
||||
let obj: FabricObjectWithPenId | undefined
|
||||
const existingObj = objMap.get(node.id)
|
||||
let existingObj = objMap.get(node.id)
|
||||
|
||||
// Text nodes may need recreation when textGrowth mode changes
|
||||
// (IText ↔ Textbox are different Fabric classes).
|
||||
let textRecreated = false
|
||||
if (existingObj && node.type === 'text') {
|
||||
const growth = node.textGrowth
|
||||
const w = typeof node.width === 'number' ? node.width : 0
|
||||
const needsTextbox = growth === 'fixed-width' || growth === 'fixed-width-height' || w > 0
|
||||
const isTextbox = existingObj instanceof fabric.Textbox
|
||||
if (needsTextbox !== isTextbox) {
|
||||
canvas.remove(existingObj)
|
||||
existingObj = undefined
|
||||
textRecreated = true
|
||||
}
|
||||
}
|
||||
|
||||
if (existingObj) {
|
||||
// Skip objects inside an ActiveSelection — their left/top are
|
||||
// group-relative, not absolute. Setting absolute values from
|
||||
// the store would move them to wrong positions (snap-back bug).
|
||||
if (existingObj.group instanceof fabric.ActiveSelection) {
|
||||
continue
|
||||
}
|
||||
syncFabricObject(existingObj, node)
|
||||
obj = existingObj
|
||||
} else {
|
||||
|
|
@ -603,12 +773,34 @@ export function useCanvasSync() {
|
|||
} else {
|
||||
canvas.add(newObj)
|
||||
}
|
||||
// Restore Fabric selection when text object was recreated
|
||||
if (textRecreated) {
|
||||
const { activeId } = useCanvasStore.getState().selection
|
||||
if (activeId === node.id) {
|
||||
canvas.setActiveObject(newObj)
|
||||
}
|
||||
}
|
||||
obj = newObj
|
||||
}
|
||||
}
|
||||
|
||||
// Apply clip path from parent frame with cornerRadius
|
||||
if (obj) {
|
||||
// Component/instance selection border styling
|
||||
if (reusableIds.has(node.id)) {
|
||||
obj.borderColor = COMPONENT_COLOR
|
||||
obj.cornerColor = COMPONENT_COLOR
|
||||
obj.borderDashArray = []
|
||||
} else if (instanceIds.has(node.id)) {
|
||||
obj.borderColor = INSTANCE_COLOR
|
||||
obj.cornerColor = INSTANCE_COLOR
|
||||
obj.borderDashArray = [4, 4]
|
||||
} else if (obj.borderColor === COMPONENT_COLOR || obj.borderColor === INSTANCE_COLOR) {
|
||||
obj.borderColor = SELECTION_BLUE
|
||||
obj.cornerColor = SELECTION_BLUE
|
||||
obj.borderDashArray = []
|
||||
}
|
||||
|
||||
// Apply clip path from parent frame with cornerRadius
|
||||
const clip = clipMap.get(node.id)
|
||||
if (clip) {
|
||||
obj.clipPath = new fabric.Rect({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
import * as fabric from 'fabric'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { nodeRenderInfo } from './use-canvas-sync'
|
||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
|
||||
export function useDimensionLabel(
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
|
|
@ -40,10 +43,45 @@ export function useDimensionLabel(
|
|||
}
|
||||
|
||||
const bound = active.getBoundingRect()
|
||||
const w = Math.round(active.getScaledWidth())
|
||||
const h = Math.round(active.getScaledHeight())
|
||||
|
||||
label.textContent = `${w} \u00d7 ${h}`
|
||||
// Detect active interaction (dragging or scaling)
|
||||
const transform = (canvas as unknown as { _currentTransform?: { action?: string } })
|
||||
._currentTransform
|
||||
const action = transform?.action
|
||||
|
||||
if (!action) {
|
||||
// No active interaction — hide the label
|
||||
label.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'drag') {
|
||||
// Show position in scene coordinates during drag
|
||||
const vpt = canvas.viewportTransform
|
||||
const zoom = vpt[0]
|
||||
const panX = vpt[4]
|
||||
const panY = vpt[5]
|
||||
|
||||
if (active instanceof fabric.ActiveSelection) {
|
||||
const sceneX = Math.round(((bound.left - panX) / zoom))
|
||||
const sceneY = Math.round(((bound.top - panY) / zoom))
|
||||
label.textContent = `X: ${sceneX} Y: ${sceneY}`
|
||||
} else {
|
||||
const obj = active as FabricObjectWithPenId
|
||||
const info = obj.penNodeId ? nodeRenderInfo.get(obj.penNodeId) : null
|
||||
const offsetX = info?.parentOffsetX ?? 0
|
||||
const offsetY = info?.parentOffsetY ?? 0
|
||||
const relX = Math.round((active.left ?? 0) - offsetX)
|
||||
const relY = Math.round((active.top ?? 0) - offsetY)
|
||||
label.textContent = `X: ${relX} Y: ${relY}`
|
||||
}
|
||||
} else {
|
||||
// Show dimensions during scale/resize
|
||||
const w = Math.round(active.getScaledWidth())
|
||||
const h = Math.round(active.getScaledHeight())
|
||||
label.textContent = `${w} \u00d7 ${h}`
|
||||
}
|
||||
|
||||
label.style.display = 'block'
|
||||
label.style.left = `${bound.left + bound.width / 2}px`
|
||||
label.style.top = `${bound.top + bound.height + 8}px`
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export function useFabricCanvas(
|
|||
height: container.clientHeight,
|
||||
backgroundColor: getCanvasBackground(),
|
||||
selection: true,
|
||||
selectionKey: 'shiftKey',
|
||||
preserveObjectStacking: true,
|
||||
stopContextMenu: true,
|
||||
fireRightClick: true,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,38 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { COMPONENT_COLOR, INSTANCE_COLOR } from './canvas-constants'
|
||||
import type { FabricObjectWithPenId } from './canvas-object-factory'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
|
||||
const LABEL_FONT_SIZE = 12
|
||||
const LABEL_OFFSET_Y = 6
|
||||
const LABEL_COLOR = '#999999'
|
||||
|
||||
/** Collect IDs of all nodes with reusable: true in the tree. */
|
||||
function collectReusableIds(nodes: PenNode[], result: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if ('reusable' in node && node.reusable === true) {
|
||||
result.add(node.id)
|
||||
}
|
||||
if ('children' in node && node.children) {
|
||||
collectReusableIds(node.children, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect IDs of all RefNodes (instances) in the tree. */
|
||||
function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'ref') {
|
||||
result.add(node.id)
|
||||
}
|
||||
if ('children' in node && node.children) {
|
||||
collectInstanceIds(node.children, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useFrameLabels() {
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -26,22 +52,33 @@ export function useFrameLabels() {
|
|||
const dpr = el.width / el.offsetWidth
|
||||
|
||||
const store = useDocumentStore.getState()
|
||||
// Only top-level frame nodes show labels
|
||||
// Top-level frame nodes show labels
|
||||
const topFrameIds = new Set(
|
||||
store.document.children
|
||||
.filter((c) => c.type === 'frame')
|
||||
.map((c) => c.id),
|
||||
)
|
||||
// Reusable component IDs (at any depth)
|
||||
const reusableIds = new Set<string>()
|
||||
collectReusableIds(store.document.children, reusableIds)
|
||||
// Instance (RefNode) IDs
|
||||
const instanceIds = new Set<string>()
|
||||
collectInstanceIds(store.document.children, instanceIds)
|
||||
|
||||
const objects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
|
||||
ctx.save()
|
||||
const fontSize = LABEL_FONT_SIZE * dpr
|
||||
ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
||||
ctx.fillStyle = LABEL_COLOR
|
||||
|
||||
for (const obj of objects) {
|
||||
if (!obj.penNodeId) continue
|
||||
if (!topFrameIds.has(obj.penNodeId)) continue
|
||||
|
||||
const isTopFrame = topFrameIds.has(obj.penNodeId)
|
||||
const isReusable = reusableIds.has(obj.penNodeId)
|
||||
const isInstance = instanceIds.has(obj.penNodeId)
|
||||
|
||||
if (!isTopFrame && !isReusable && !isInstance) continue
|
||||
|
||||
const node = store.getNodeById(obj.penNodeId)
|
||||
if (!node) continue
|
||||
|
|
@ -50,6 +87,11 @@ export function useFrameLabels() {
|
|||
const x = ((obj.left ?? 0) * zoom + vpt[4]) * dpr
|
||||
const y = ((obj.top ?? 0) * zoom + vpt[5]) * dpr
|
||||
|
||||
ctx.fillStyle = isReusable
|
||||
? COMPONENT_COLOR
|
||||
: isInstance
|
||||
? INSTANCE_COLOR
|
||||
: LABEL_COLOR
|
||||
ctx.fillText(name, x, y - LABEL_OFFSET_Y * dpr)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import PropertyPanel from '@/components/panels/property-panel'
|
|||
import AIChatPanel, { AIChatMinimizedBar } from '@/components/panels/ai-chat-panel'
|
||||
import CodePanel from '@/components/panels/code-panel'
|
||||
import VariablesPanel from '@/components/panels/variables-panel'
|
||||
import ComponentBrowserPanel from '@/components/panels/component-browser-panel'
|
||||
import ExportDialog from '@/components/shared/export-dialog'
|
||||
import SaveDialog from '@/components/shared/save-dialog'
|
||||
import AgentSettingsDialog from '@/components/shared/agent-settings-dialog'
|
||||
|
|
@ -15,6 +16,7 @@ import { useAIStore } from '@/stores/ai-store'
|
|||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { useAgentSettingsStore } from '@/stores/agent-settings-store'
|
||||
import { useUIKitStore } from '@/stores/uikit-store'
|
||||
|
||||
const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas'))
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ export default function EditorLayout() {
|
|||
const hasSelection = useCanvasStore((s) => s.selection.activeId !== null)
|
||||
const layerPanelOpen = useCanvasStore((s) => s.layerPanelOpen)
|
||||
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
|
||||
const browserOpen = useUIKitStore((s) => s.browserOpen)
|
||||
const saveDialogOpen = useDocumentStore((s) => s.saveDialogOpen)
|
||||
const closeSaveDialog = useCallback(() => {
|
||||
useDocumentStore.getState().setSaveDialogOpen(false)
|
||||
|
|
@ -70,6 +73,13 @@ export default function EditorLayout() {
|
|||
return
|
||||
}
|
||||
|
||||
// Cmd+Shift+K: toggle UIKit browser
|
||||
if (isMod && e.shiftKey && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
useUIKitStore.getState().toggleBrowser()
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd+,: open agent settings
|
||||
if (isMod && e.key === ',') {
|
||||
e.preventDefault()
|
||||
|
|
@ -81,9 +91,10 @@ export default function EditorLayout() {
|
|||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [toggleMinimize, toggleCodePanel])
|
||||
|
||||
// Hydrate persisted agent settings
|
||||
// Hydrate persisted settings
|
||||
useEffect(() => {
|
||||
useAgentSettingsStore.getState().hydrate()
|
||||
useUIKitStore.getState().hydrate()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
|
@ -108,6 +119,9 @@ export default function EditorLayout() {
|
|||
{/* Floating variables panel — anchored to the right of the toolbar */}
|
||||
{variablesPanelOpen && <VariablesPanel />}
|
||||
|
||||
{/* Floating UIKit browser panel */}
|
||||
{browserOpen && <ComponentBrowserPanel />}
|
||||
|
||||
{/* Bottom bar: minimized AI (left) + zoom controls (right) */}
|
||||
<div className="absolute bottom-2 left-2 right-2 z-10 flex items-center justify-between pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import {
|
|||
Hand,
|
||||
Undo2,
|
||||
Redo2,
|
||||
SlidersHorizontal,
|
||||
Braces,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react'
|
||||
import ToolButton from './tool-button'
|
||||
import ShapeToolDropdown from './shape-tool-dropdown'
|
||||
|
|
@ -14,6 +15,7 @@ import { useCanvasStore } from '@/stores/canvas-store'
|
|||
import { useDocumentStore, generateId } from '@/stores/document-store'
|
||||
import { parseSvgToNodes } from '@/utils/svg-parser'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import { useUIKitStore } from '@/stores/uikit-store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
|
|
@ -28,6 +30,8 @@ export default function Toolbar() {
|
|||
const canRedo = useHistoryStore((s) => s.redoStack.length > 0)
|
||||
const variablesPanelOpen = useCanvasStore((s) => s.variablesPanelOpen)
|
||||
const toggleVariablesPanel = useCanvasStore((s) => s.toggleVariablesPanel)
|
||||
const browserOpen = useUIKitStore((s) => s.browserOpen)
|
||||
const toggleBrowser = useUIKitStore((s) => s.toggleBrowser)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
|
||||
|
|
@ -245,7 +249,7 @@ export default function Toolbar() {
|
|||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal size={20} />
|
||||
<Braces size={20} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
|
|
@ -256,6 +260,31 @@ export default function Toolbar() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* UIKit Browser */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBrowser}
|
||||
aria-label="UIKit Browser"
|
||||
aria-pressed={browserOpen}
|
||||
className={`inline-flex items-center justify-center h-8 min-w-8 px-1.5 rounded-lg transition-colors [&_svg]:size-5 [&_svg]:shrink-0 ${
|
||||
browserOpen
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
UIKit Browser
|
||||
<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">
|
||||
{'\u2318\u21e7'}K
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Hidden file input + icon picker dialog */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import ClaudeLogo from '@/components/icons/claude-logo'
|
||||
import OpenAILogo from '@/components/icons/openai-logo'
|
||||
import OpenCodeLogo from '@/components/icons/opencode-logo'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
|
|
@ -95,8 +96,11 @@ export default function TopBar() {
|
|||
|
||||
if (fileHandle) {
|
||||
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
|
||||
} else if (fn) {
|
||||
downloadDocument(doc, fn)
|
||||
store.markClean()
|
||||
} else if (supportsFileSystemAccess()) {
|
||||
saveDocumentAs(doc, fn ?? 'untitled.pen').then((result) => {
|
||||
saveDocumentAs(doc, 'untitled.op').then((result) => {
|
||||
if (result) {
|
||||
useDocumentStore.setState({
|
||||
fileName: result.fileName,
|
||||
|
|
@ -105,9 +109,6 @@ export default function TopBar() {
|
|||
})
|
||||
}
|
||||
})
|
||||
} else if (fn) {
|
||||
downloadDocument(doc, fn)
|
||||
store.markClean()
|
||||
} else {
|
||||
store.setSaveDialogOpen(true)
|
||||
}
|
||||
|
|
@ -136,9 +137,9 @@ export default function TopBar() {
|
|||
const displayName = fileName ?? 'Untitled'
|
||||
|
||||
return (
|
||||
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none">
|
||||
<div className="h-10 bg-card border-b border-border flex items-center px-2 shrink-0 select-none app-region-drag">
|
||||
{/* Left section */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-0.5 app-region-no-drag electron-traffic-light-pad">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -198,7 +199,7 @@ export default function TopBar() {
|
|||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-0.5 app-region-no-drag">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -209,6 +210,7 @@ export default function TopBar() {
|
|||
>
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
<OpenAILogo className="w-4 h-4 -ml-1" />
|
||||
<OpenCodeLogo className="w-4 h-4 -ml-1" />
|
||||
<span className="hidden sm:inline">Agents & MCP</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
|
|
|||
63
src/components/icons/opencode-logo.tsx
Normal file
63
src/components/icons/opencode-logo.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { SVGProps } from 'react'
|
||||
|
||||
export default function OpenCodeLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<g clipPath="url(#oc-clip)">
|
||||
{/* Terminal window frame */}
|
||||
<rect
|
||||
x="12"
|
||||
y="14"
|
||||
width="40"
|
||||
height="36"
|
||||
rx="4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Title bar line */}
|
||||
<line
|
||||
x1="12"
|
||||
y1="22"
|
||||
x2="52"
|
||||
y2="22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
{/* Title bar dots */}
|
||||
<circle cx="18" cy="18" r="1.5" fill="currentColor" />
|
||||
<circle cx="23" cy="18" r="1.5" fill="currentColor" />
|
||||
<circle cx="28" cy="18" r="1.5" fill="currentColor" />
|
||||
{/* Terminal prompt chevron > */}
|
||||
<path
|
||||
d="M18 29L26 35L18 41"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
{/* Cursor line */}
|
||||
<line
|
||||
x1="30"
|
||||
y1="40"
|
||||
x2="44"
|
||||
y2="40"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="oc-clip">
|
||||
<rect width="40" height="40" fill="white" transform="translate(12 12)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2 } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Send, Plus, ChevronDown, ChevronUp, Check, MessageSquare, Loader2, Pencil } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -19,15 +19,23 @@ import {
|
|||
extractAndApplyDesign,
|
||||
extractAndApplyDesignModification
|
||||
} from '@/services/ai/design-generator'
|
||||
import { trimChatHistory } from '@/services/ai/context-optimizer'
|
||||
import type { ChatMessage as ChatMessageType } from '@/services/ai/ai-types'
|
||||
import type { AIProviderType } from '@/types/agent-settings'
|
||||
import { CHAT_STREAM_THINKING_CONFIG } from '@/services/ai/ai-runtime-config'
|
||||
import ClaudeLogo from '@/components/icons/claude-logo'
|
||||
import OpenAILogo from '@/components/icons/openai-logo'
|
||||
import ChatMessage from './chat-message'
|
||||
import OpenCodeLogo from '@/components/icons/opencode-logo'
|
||||
import ChatMessage, {
|
||||
parseStepBlocks,
|
||||
countDesignJsonBlocks,
|
||||
buildPipelineProgress,
|
||||
} from './chat-message'
|
||||
|
||||
const PROVIDER_ICON: Record<AIProviderType, typeof ClaudeLogo> = {
|
||||
anthropic: ClaudeLogo,
|
||||
openai: OpenAILogo,
|
||||
opencode: OpenCodeLogo,
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
|
|
@ -117,7 +125,6 @@ function useChatHandlers() {
|
|||
const addMessage = useAIStore((s) => s.addMessage)
|
||||
const updateLastMessage = useAIStore((s) => s.updateLastMessage)
|
||||
const setStreaming = useAIStore((s) => s.setStreaming)
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text?: string) => {
|
||||
const messageText = text ?? input.trim()
|
||||
|
|
@ -221,8 +228,20 @@ function useChatHandlers() {
|
|||
} else {
|
||||
// --- CHAT MODE ---
|
||||
chatHistory.push({ role: 'user', content: fullUserMessage })
|
||||
// Trim history to prevent unbounded context growth
|
||||
const trimmedHistory = trimChatHistory(chatHistory)
|
||||
// Resolve which provider the currently selected model belongs to
|
||||
const currentProvider = useAIStore.getState().modelGroups.find((g) =>
|
||||
g.models.some((m) => m.value === model),
|
||||
)?.provider
|
||||
let chatThinking = false
|
||||
for await (const chunk of streamChat(CHAT_SYSTEM_PROMPT, chatHistory, model)) {
|
||||
for await (const chunk of streamChat(
|
||||
CHAT_SYSTEM_PROMPT,
|
||||
trimmedHistory,
|
||||
model,
|
||||
CHAT_STREAM_THINKING_CONFIG,
|
||||
currentProvider,
|
||||
)) {
|
||||
if (chunk.type === 'thinking') {
|
||||
// Show a brief thinking indicator so the UI isn't stuck on empty
|
||||
if (!chatThinking && !accumulated) {
|
||||
|
|
@ -268,6 +287,88 @@ function useChatHandlers() {
|
|||
return { input, setInput, handleSend, isStreaming }
|
||||
}
|
||||
|
||||
/** Fixed collapsible checklist pinned between messages and input */
|
||||
function FixedChecklist({ messages, isStreaming }: { messages: ChatMessageType[]; isStreaming: boolean }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
// Find the last assistant message to extract checklist data
|
||||
const lastAssistant = useMemo(() => {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') return messages[i]
|
||||
}
|
||||
return null
|
||||
}, [messages])
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!lastAssistant) return []
|
||||
const content = lastAssistant.content
|
||||
const steps = parseStepBlocks(content, isStreaming)
|
||||
const planSteps = steps.filter((s) => s.title !== 'Thinking')
|
||||
if (planSteps.length === 0) return []
|
||||
const jsonCount = countDesignJsonBlocks(content)
|
||||
const isApplied = content.includes('\u2705') || content.includes('<!-- APPLIED -->')
|
||||
const hasError = /\*\*Error:\*\*/i.test(content)
|
||||
return buildPipelineProgress(planSteps, jsonCount, isStreaming, isApplied, hasError)
|
||||
}, [lastAssistant, isStreaming])
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
const completed = items.filter((item) => item.done).length
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border bg-card/95">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-between w-full px-3 py-2 hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={13} className="text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-medium text-foreground">Pencil it out</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{completed}/{items.length}</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
'text-muted-foreground transition-transform duration-200',
|
||||
collapsed ? '' : 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-2.5 flex max-h-44 flex-col gap-1 overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.label}-${index}`} className="flex items-center gap-2 text-[11px] text-muted-foreground/90">
|
||||
<span
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 rounded-full border flex items-center justify-center shrink-0',
|
||||
item.done
|
||||
? 'border-emerald-500/70 text-emerald-500/80'
|
||||
: item.active
|
||||
? 'border-primary/70 text-primary'
|
||||
: 'border-border/70 text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{item.done ? (
|
||||
<Check size={9} strokeWidth={2.5} />
|
||||
) : (
|
||||
<span className={cn(
|
||||
'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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimized AI bar — a compact clickable pill.
|
||||
* Parent is responsible for placing it in the layout.
|
||||
|
|
@ -343,6 +444,7 @@ export default function AIChatPanel() {
|
|||
const providerNames: Record<AIProviderType, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
opencode: 'OpenCode',
|
||||
}
|
||||
const groups = connectedProviders.map((p) => ({
|
||||
provider: p,
|
||||
|
|
@ -587,7 +689,7 @@ export default function AIChatPanel() {
|
|||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
'absolute z-50 w-[320px] rounded-xl shadow-2xl border border-border bg-card/95 backdrop-blur-sm flex flex-col',
|
||||
'absolute z-50 flex w-[320px] flex-col overflow-hidden rounded-xl border border-border bg-card/95 shadow-2xl backdrop-blur-sm',
|
||||
!dragStyle && CORNER_CLASSES[panelCorner],
|
||||
)}
|
||||
style={{ ...dragStyle, height: panelHeight }}
|
||||
|
|
@ -635,7 +737,7 @@ export default function AIChatPanel() {
|
|||
</div>
|
||||
|
||||
{/* --- Messages --- */}
|
||||
<div className="flex-1 overflow-y-auto px-3.5 py-3 bg-background/80 rounded-b-xl">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-b-xl bg-background/80 px-3.5 py-3">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
|
|
@ -671,6 +773,9 @@ export default function AIChatPanel() {
|
|||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* --- Fixed Checklist --- */}
|
||||
<FixedChecklist messages={messages} isStreaming={isStreaming} />
|
||||
|
||||
{/* --- Input area --- */}
|
||||
<div className="relative border-t border-border bg-card rounded-b-xl">
|
||||
<textarea
|
||||
|
|
@ -711,15 +816,18 @@ export default function AIChatPanel() {
|
|||
<ChevronUp size={10} className="shrink-0" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 justify-between w-full">
|
||||
{selectedIds.length > 0 && (
|
||||
<span className="flex text-[10px] text-muted-foreground ml-2 select-none overflow-hidden text-ellipsis ">
|
||||
{selectedIds.length} object{selectedIds.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 shrink-0 whitespace-nowrap text-[10px] select-none',
|
||||
selectedIds.length > 0 ? 'text-muted-foreground/80' : 'text-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
{selectedIds.length} selected
|
||||
</span>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 w-full justify-end">
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useMemo, type ReactNode } from 'react'
|
||||
import { Copy, Check, Wand2, ChevronDown, ScanSearch, FileJson, ListOrdered, Palette, LayoutTemplate } from 'lucide-react'
|
||||
import { Copy, Check, Wand2, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
|
|
@ -46,46 +46,158 @@ function stripToolCallXml(text: string): string {
|
|||
return cleaned.trim()
|
||||
}
|
||||
|
||||
/** Check if a line is a step action */
|
||||
function isActionStep(line: string): boolean {
|
||||
return /<step.*<\/step>/.test(line) || line.trim().startsWith('<step')
|
||||
export interface ParsedStep {
|
||||
title: string
|
||||
content: string
|
||||
/** Explicit status from orchestrator steps (undefined for normal steps) */
|
||||
status?: 'pending' | 'streaming' | 'done' | 'error'
|
||||
}
|
||||
|
||||
function parseStepTitle(step: string): string {
|
||||
const match = step.match(/title="([^"]+)"/)
|
||||
return match ? match[1] : 'Processing'
|
||||
export function parseStepBlocks(text: string, isStreaming?: boolean): ParsedStep[] {
|
||||
const stepRegex = /<step([^>]*)>([\s\S]*?)<\/step>/gi
|
||||
const parsed: ParsedStep[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = stepRegex.exec(text)) !== null) {
|
||||
const attrs = match[1]
|
||||
const titleMatch = attrs.match(/title="([^"]+)"/)
|
||||
const statusMatch = attrs.match(/status="([^"]+)"/)
|
||||
parsed.push({
|
||||
title: (titleMatch?.[1] ?? 'Processing').trim() || 'Processing',
|
||||
status: (statusMatch?.[1] as ParsedStep['status']) ?? undefined,
|
||||
content: (match[2] ?? '').trim(),
|
||||
})
|
||||
}
|
||||
|
||||
const lastOpen = text.lastIndexOf('<step')
|
||||
const lastClose = text.lastIndexOf('</step>')
|
||||
if (isStreaming && lastOpen > lastClose) {
|
||||
const partial = text.slice(lastOpen)
|
||||
const titleMatch = partial.match(/title="([^"]+)"/i)
|
||||
const statusMatch = partial.match(/status="([^"]+)"/i)
|
||||
const contentStart = partial.indexOf('>')
|
||||
parsed.push({
|
||||
title: (titleMatch?.[1] ?? 'Design').trim() || 'Design',
|
||||
status: (statusMatch?.[1] as ParsedStep['status']) ?? undefined,
|
||||
content:
|
||||
contentStart >= 0
|
||||
? partial
|
||||
.slice(contentStart + 1)
|
||||
.replace(/<\/step>$/i, '')
|
||||
.trim()
|
||||
: '',
|
||||
})
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseStepContent(step: string): string {
|
||||
return step.replace(/<step[^>]*>/, '').replace(/<\/step>/, '').trim()
|
||||
function stripStepBlocks(text: string): string {
|
||||
return text
|
||||
.replace(/<step(?:[^>]*title="[^"]*")?[^>]*>[\s\S]*?<\/step>/gi, '')
|
||||
.replace(/<step(?:[^>]*title="[^"]*")?[^>]*>[\s\S]*$/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Component for rendering a list of action steps as accordions */
|
||||
function ActionSteps({ steps }: { steps: string[] }) {
|
||||
if (steps.length === 0) return null
|
||||
|
||||
/** Count completed sections in JSONL content (direct children of root frame). */
|
||||
function countJsonlSections(content: string): number {
|
||||
const lines = content.split('\n')
|
||||
let rootId: string | null = null
|
||||
let sectionCount = 0
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('{')) continue
|
||||
|
||||
const parentMatch = trimmed.match(/"_parent"\s*:\s*(null|"([^"]*)")/)
|
||||
if (!parentMatch) continue
|
||||
|
||||
if (parentMatch[1] === 'null') {
|
||||
const idMatch = trimmed.match(/"id"\s*:\s*"([^"]*)"/)
|
||||
if (idMatch) rootId = idMatch[1]
|
||||
} else if (rootId && parentMatch[2] === rootId) {
|
||||
sectionCount++
|
||||
}
|
||||
}
|
||||
|
||||
return sectionCount
|
||||
}
|
||||
|
||||
export function countDesignJsonBlocks(text: string): number {
|
||||
const blockRegex = /```(?:json)?\s*\n?([\s\S]*?)(?:\n?```|$)/gi
|
||||
let count = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = blockRegex.exec(text)) !== null) {
|
||||
const content = match[1].trim()
|
||||
if (!isDesignJson(content)) continue
|
||||
|
||||
// JSONL format: count direct children of root as sections
|
||||
if (/"_parent"\s*:/.test(content)) {
|
||||
count += countJsonlSections(content)
|
||||
} else {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export function buildPipelineProgress(
|
||||
steps: ParsedStep[],
|
||||
jsonBlockCount: number,
|
||||
isStreaming: boolean,
|
||||
isApplied: boolean,
|
||||
hasError: boolean,
|
||||
): Array<{ label: string; done: boolean; active: boolean }> {
|
||||
// No steps = no checklist
|
||||
if (steps.length === 0) return []
|
||||
|
||||
// If generation is complete and applied, mark all steps done
|
||||
const hasTerminalResult = !isStreaming && !hasError && (isApplied || jsonBlockCount > 0)
|
||||
if (hasTerminalResult) {
|
||||
return steps.map((s) => ({ label: s.title, done: true, active: false }))
|
||||
}
|
||||
|
||||
// If steps have explicit status (orchestrator mode), use that directly
|
||||
const hasExplicitStatus = steps.some((s) => s.status !== undefined)
|
||||
if (hasExplicitStatus) {
|
||||
return steps.map((s) => ({
|
||||
label: s.title,
|
||||
done: s.status === 'done',
|
||||
active: s.status === 'streaming',
|
||||
}))
|
||||
}
|
||||
|
||||
// Fallback: Map each step to done/active/pending based on completed JSON blocks.
|
||||
// Step[i] is done when jsonBlockCount > i.
|
||||
// The step at jsonBlockCount is active (currently being generated).
|
||||
return steps.map((s, index) => {
|
||||
const done = index < jsonBlockCount
|
||||
const active = isStreaming && !done && index === jsonBlockCount
|
||||
return { label: s.title, done, active }
|
||||
})
|
||||
}
|
||||
|
||||
/** Component for rendering a list of action steps as accordions.
|
||||
* Only shows steps with non-empty content (e.g. thinking, analysis).
|
||||
* Empty plan steps are shown in PipelineChecklist instead. */
|
||||
function ActionSteps({ steps, isStreaming }: { steps: ParsedStep[]; isStreaming?: boolean }) {
|
||||
// Filter to only show steps with actual content (not empty plan steps)
|
||||
const stepsWithContent = steps.filter((s) => s.content.trim())
|
||||
if (stepsWithContent.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
{steps.map((step, i) => {
|
||||
const title = parseStepTitle(step)
|
||||
const content = parseStepContent(step)
|
||||
const isDesign = title.toLowerCase() === 'design'
|
||||
|
||||
// Icon mapping based on step title
|
||||
let Icon = ScanSearch
|
||||
if (title.toLowerCase().includes('guidelines')) Icon = FileJson
|
||||
if (title.toLowerCase().includes('state')) Icon = ListOrdered
|
||||
if (title.toLowerCase().includes('styleguide')) Icon = Palette
|
||||
if (isDesign) Icon = LayoutTemplate
|
||||
|
||||
{stepsWithContent.map((step, i) => {
|
||||
const isDone = !isStreaming || i < stepsWithContent.length - 1
|
||||
const isActive = !!isStreaming && i === stepsWithContent.length - 1
|
||||
return (
|
||||
<ActionStepItem
|
||||
key={i}
|
||||
title={title}
|
||||
content={content}
|
||||
icon={Icon}
|
||||
defaultOpen={isDesign}
|
||||
isLast={i === steps.length - 1}
|
||||
<ActionStepItem
|
||||
key={`${step.title}-${i}`}
|
||||
title={step.title}
|
||||
content={step.content}
|
||||
defaultOpen={isActive}
|
||||
isDone={isDone}
|
||||
isActive={isActive}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
|
@ -93,67 +205,65 @@ function ActionSteps({ steps }: { steps: string[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function ActionStepItem({
|
||||
title,
|
||||
content,
|
||||
icon: _Icon,
|
||||
function ActionStepItem({
|
||||
title,
|
||||
content,
|
||||
defaultOpen = false,
|
||||
isLast
|
||||
}: {
|
||||
isDone,
|
||||
isActive,
|
||||
}: {
|
||||
title: string
|
||||
content: string
|
||||
icon: any
|
||||
defaultOpen?: boolean
|
||||
isLast?: boolean
|
||||
defaultOpen?: boolean
|
||||
isDone: boolean
|
||||
isActive: boolean
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen)
|
||||
|
||||
// Pencil-like style: Rounded pill/card with subtle border
|
||||
return (
|
||||
<div className="group">
|
||||
<button
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex items-center justify-between w-full px-3 py-2 text-left transition-all rounded-md border",
|
||||
isOpen
|
||||
? "bg-secondary/40 border-border/60"
|
||||
: "bg-background/40 hover:bg-secondary/20 border-border/30 hover:border-border/50"
|
||||
'flex items-center justify-between w-full px-3 py-2 text-left transition-all rounded-md border',
|
||||
isOpen
|
||||
? 'bg-secondary/40 border-border/60'
|
||||
: 'bg-background/40 hover:bg-secondary/20 border-border/30 hover:border-border/50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
{/* Status Icon - Pencil puts it on the right usually, but we can keep left for flow or move right */}
|
||||
{/* Actually looking at Pencil screenshot: Text is left, checkmark is INLINE or right. */}
|
||||
{/* Let's keep consistent icon on left for now, but style it like a status indicator */}
|
||||
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-colors",
|
||||
isLast
|
||||
? "text-primary scale-110"
|
||||
: "text-emerald-500/80" // Green for completed steps like Pencil
|
||||
)}>
|
||||
{isLast ? (
|
||||
<div className="w-2 h-2 rounded-full bg-primary animate-pulse shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 h-4 rounded-full flex items-center justify-center shrink-0 transition-colors',
|
||||
isDone ? 'text-emerald-500/80' : isActive ? 'text-primary' : 'text-muted-foreground/50',
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
) : (
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
<div className={cn('w-2 h-2 rounded-full', isActive ? 'bg-primary animate-pulse' : 'bg-muted-foreground/60')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span title={title} className={cn(
|
||||
"text-[11px] font-medium transition-colors truncate select-none",
|
||||
isLast ? "text-foreground" : "text-muted-foreground/90"
|
||||
)}>
|
||||
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
'text-[11px] font-medium transition-colors truncate select-none',
|
||||
isDone ? 'text-muted-foreground/90' : isActive ? 'text-foreground' : 'text-muted-foreground/70',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center text-muted-foreground/30">
|
||||
<ChevronDown size={12} className={cn("transition-transform duration-200", isOpen ? "rotate-180" : "")} />
|
||||
<ChevronDown size={12} className={cn('transition-transform duration-200', isOpen ? 'rotate-180' : '')} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen && content && (
|
||||
<div className="px-3 py-2 mx-1 mt-0.5 border-l border-border/30 text-[10px] text-muted-foreground/80 leading-relaxed font-mono animate-in slide-in-from-top-0.5 duration-200 whitespace-pre-wrap break-words">
|
||||
{content}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -178,56 +288,7 @@ function parseMarkdown(
|
|||
let codeLang = ''
|
||||
let blockKey = 0
|
||||
|
||||
// Pre-process: extract sequential steps at the start or throughout?
|
||||
// Our prompt puts them at the start. Let's process line by line.
|
||||
// If we encounter steps, we collect them. If we encounter non-step content, we flush steps.
|
||||
|
||||
// Actually, simpler: Treat <step> lines as special blocks.
|
||||
|
||||
let currentSteps: string[] = []
|
||||
|
||||
const flushSteps = () => {
|
||||
if (currentSteps.length > 0) {
|
||||
parts.push(<ActionSteps key={`steps-${blockKey++}`} steps={[...currentSteps]} />)
|
||||
currentSteps = []
|
||||
}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('</step>') && !line.includes('<step')) {
|
||||
const withoutCloseStep = line.replace(/<\/step>/g, '').trim()
|
||||
if (!withoutCloseStep) continue
|
||||
}
|
||||
|
||||
if (/^\s*<\/step>\s*$/.test(line)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isActionStep(line)) {
|
||||
// Check if it's a complete step or partial (streaming)
|
||||
// For now assume complete lines or handle partials if needed
|
||||
// If valid step line, add to current buffer
|
||||
currentSteps.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
// specific hack for streaming partially completed step
|
||||
if (line.trim().startsWith('<step') && !line.trim().includes('</step>')) {
|
||||
// It's a streaming step, possibly unfinished.
|
||||
// We can show it as "Working..." or just text.
|
||||
// Let's just treat it as a step for now?
|
||||
currentSteps.push(line + '</step>') // Auto-close for display
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*<step[^>]*>\s*$/.test(line)) {
|
||||
currentSteps.push(line + '</step>')
|
||||
continue
|
||||
}
|
||||
|
||||
// Not a step -> flush any pending steps
|
||||
flushSteps()
|
||||
|
||||
if (line.startsWith('```') && !inCodeBlock) {
|
||||
inCodeBlock = true
|
||||
codeLang = line.slice(3).trim()
|
||||
|
|
@ -267,12 +328,8 @@ function parseMarkdown(
|
|||
|
||||
// Empty lines
|
||||
if (!line) {
|
||||
// If we're inside a sequence of steps, just ignore the blank line to group them together
|
||||
if (currentSteps.length > 0) continue
|
||||
|
||||
// Otherwise render as newline
|
||||
parts.push('\n')
|
||||
continue
|
||||
parts.push('\n')
|
||||
continue
|
||||
}
|
||||
|
||||
parts.push(
|
||||
|
|
@ -283,9 +340,6 @@ function parseMarkdown(
|
|||
)
|
||||
}
|
||||
|
||||
// Flush remaining steps at the end
|
||||
flushSteps()
|
||||
|
||||
// Handle unclosed code block (streaming)
|
||||
if (inCodeBlock && codeContent) {
|
||||
const code = codeContent.trimEnd()
|
||||
|
|
@ -449,6 +503,10 @@ function DesignJsonBlock({
|
|||
if (Array.isArray(parsed)) return parsed.length
|
||||
return 1
|
||||
} catch {
|
||||
// JSONL format: count lines that look like JSON objects
|
||||
if (/"_parent"\s*:/.test(code)) {
|
||||
return code.split('\n').filter(line => line.trim().startsWith('{')).length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}, [code])
|
||||
|
|
@ -543,7 +601,16 @@ export default function ChatMessage({
|
|||
const isUser = role === 'user'
|
||||
// Strip raw tool-call XML that the model may emit (should never be visible)
|
||||
const displayContent = isUser ? content : stripToolCallXml(content)
|
||||
const isEmpty = !displayContent.trim()
|
||||
const steps = useMemo(
|
||||
() => (isUser ? [] : parseStepBlocks(displayContent, isStreaming)),
|
||||
[isUser, displayContent, isStreaming],
|
||||
)
|
||||
const hasFlow = !isUser && steps.length > 0
|
||||
const contentWithoutSteps = useMemo(
|
||||
() => (isUser ? displayContent : stripStepBlocks(displayContent)),
|
||||
[isUser, displayContent],
|
||||
)
|
||||
const isEmpty = !contentWithoutSteps.trim() && !hasFlow
|
||||
|
||||
// Don't render an empty non-streaming assistant message
|
||||
// UNLESS we stripped something out (meaning the AI did something, but we hid it).
|
||||
|
|
@ -581,9 +648,23 @@ export default function ChatMessage({
|
|||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{parseMarkdown(displayContent, onApplyDesign, isApplied, isStreaming && !isEmpty)}
|
||||
</div>
|
||||
<>
|
||||
{hasFlow && (
|
||||
<div className="mb-2">
|
||||
<ActionSteps steps={steps} isStreaming={isStreaming} />
|
||||
</div>
|
||||
)}
|
||||
{contentWithoutSteps.trim() ? (
|
||||
<div className="whitespace-pre-wrap">
|
||||
{parseMarkdown(
|
||||
contentWithoutSteps,
|
||||
onApplyDesign,
|
||||
isApplied,
|
||||
isStreaming && !!contentWithoutSteps.trim(),
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
102
src/components/panels/component-browser-card.tsx
Normal file
102
src/components/panels/component-browser-card.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { useCallback } from 'react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { KitComponent, UIKit } from '@/types/uikit'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { findReusableNode, deepCloneNode, collectVariableRefs } from '@/uikit/kit-utils'
|
||||
|
||||
interface ComponentBrowserCardProps {
|
||||
component: KitComponent
|
||||
kit: UIKit
|
||||
}
|
||||
|
||||
/** Recursively assign new IDs to a node tree so each insert is independent. */
|
||||
function reassignIds(node: PenNode): PenNode {
|
||||
const clone = { ...node, id: nanoid() }
|
||||
if ('children' in clone && Array.isArray(clone.children)) {
|
||||
clone.children = clone.children.map(reassignIds)
|
||||
}
|
||||
return clone as PenNode
|
||||
}
|
||||
|
||||
export default function ComponentBrowserCard({ component, kit }: ComponentBrowserCardProps) {
|
||||
const handleInsert = useCallback(() => {
|
||||
const { addNode, document } = useDocumentStore.getState()
|
||||
const { viewport, fabricCanvas } = useCanvasStore.getState()
|
||||
|
||||
const kitNode = findReusableNode(kit.document, component.id)
|
||||
if (!kitNode) return
|
||||
|
||||
// Copy referenced variables from the kit document
|
||||
if (kit.document.variables) {
|
||||
const refs = collectVariableRefs(kitNode)
|
||||
const { setVariable } = useDocumentStore.getState()
|
||||
for (const ref of refs) {
|
||||
const name = ref.startsWith('$') ? ref.slice(1) : ref
|
||||
const varDef = kit.document.variables[name]
|
||||
if (varDef && !document.variables?.[name]) {
|
||||
setVariable(name, varDef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep clone with new IDs, remove reusable flag so it's standalone
|
||||
const cloned = reassignIds(deepCloneNode(kitNode))
|
||||
if ('reusable' in cloned) {
|
||||
delete (cloned as unknown as Record<string, unknown>).reusable
|
||||
}
|
||||
|
||||
// Place at viewport center
|
||||
const canvasEl = fabricCanvas?.getElement()
|
||||
const canvasW = canvasEl?.clientWidth ?? 800
|
||||
const canvasH = canvasEl?.clientHeight ?? 600
|
||||
const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom
|
||||
const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom
|
||||
cloned.x = centerX - component.width / 2
|
||||
cloned.y = centerY - component.height / 2
|
||||
cloned.name = component.name
|
||||
|
||||
addNode(null, cloned)
|
||||
useCanvasStore.getState().setSelection([cloned.id], cloned.id)
|
||||
}, [component, kit])
|
||||
|
||||
// Compute preview aspect ratio
|
||||
const maxPreviewW = 120
|
||||
const maxPreviewH = 64
|
||||
const scale = Math.min(maxPreviewW / component.width, maxPreviewH / component.height, 1)
|
||||
const previewW = Math.round(component.width * scale)
|
||||
const previewH = Math.round(component.height * scale)
|
||||
|
||||
// Extract primary fill color from the kit node for preview
|
||||
const kitNode = findReusableNode(kit.document, component.id)
|
||||
let fillColor = '#E5E7EB'
|
||||
if (kitNode && 'fill' in kitNode && Array.isArray(kitNode.fill) && kitNode.fill.length > 0) {
|
||||
const f = kitNode.fill[0]
|
||||
if (f.type === 'solid' && !f.color.startsWith('$')) {
|
||||
fillColor = f.color
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInsert}
|
||||
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-border bg-card hover:bg-muted transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-center w-full h-16">
|
||||
<div
|
||||
className="rounded transition-transform group-hover:scale-105"
|
||||
style={{
|
||||
width: previewW,
|
||||
height: previewH,
|
||||
backgroundColor: fillColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-foreground truncate w-full text-center">
|
||||
{component.name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
57
src/components/panels/component-browser-grid.tsx
Normal file
57
src/components/panels/component-browser-grid.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useMemo } from 'react'
|
||||
import type { UIKit, KitComponent, ComponentCategory } from '@/types/uikit'
|
||||
import ComponentBrowserCard from './component-browser-card'
|
||||
|
||||
interface ComponentBrowserGridProps {
|
||||
kits: UIKit[]
|
||||
searchQuery: string
|
||||
activeCategory: ComponentCategory | null
|
||||
activeKitId: string | null
|
||||
}
|
||||
|
||||
export default function ComponentBrowserGrid({
|
||||
kits,
|
||||
searchQuery,
|
||||
activeCategory,
|
||||
activeKitId,
|
||||
}: ComponentBrowserGridProps) {
|
||||
const filteredItems = useMemo(() => {
|
||||
const items: { component: KitComponent; kit: UIKit }[] = []
|
||||
const query = searchQuery.toLowerCase().trim()
|
||||
|
||||
for (const kit of kits) {
|
||||
if (activeKitId && kit.id !== activeKitId) continue
|
||||
|
||||
for (const comp of kit.components) {
|
||||
if (activeCategory && comp.category !== activeCategory) continue
|
||||
if (query) {
|
||||
const nameMatch = comp.name.toLowerCase().includes(query)
|
||||
const tagMatch = comp.tags.some((t) => t.includes(query))
|
||||
if (!nameMatch && !tagMatch) continue
|
||||
}
|
||||
items.push({ component: comp, kit })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [kits, searchQuery, activeCategory, activeKitId])
|
||||
|
||||
if (filteredItems.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm py-8">
|
||||
No components found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2 p-2">
|
||||
{filteredItems.map(({ component, kit }) => (
|
||||
<ComponentBrowserCard
|
||||
key={`${kit.id}-${component.id}`}
|
||||
component={component}
|
||||
kit={kit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
290
src/components/panels/component-browser-panel.tsx
Normal file
290
src/components/panels/component-browser-panel.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { X, Search, Upload, Download, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUIKitStore } from '@/stores/uikit-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { importKitFromFile, exportKit } from '@/uikit/kit-import-export'
|
||||
import ComponentBrowserGrid from './component-browser-grid'
|
||||
import type { ComponentCategory } from '@/types/uikit'
|
||||
|
||||
const MIN_WIDTH = 420
|
||||
const MIN_HEIGHT = 300
|
||||
const DEFAULT_WIDTH = 520
|
||||
const DEFAULT_HEIGHT = 460
|
||||
|
||||
const CATEGORIES: { value: ComponentCategory | null; label: string }[] = [
|
||||
{ value: null, label: 'All' },
|
||||
{ value: 'buttons', label: 'Buttons' },
|
||||
{ value: 'inputs', label: 'Inputs' },
|
||||
{ value: 'cards', label: 'Cards' },
|
||||
{ value: 'navigation', label: 'Nav' },
|
||||
{ value: 'layout', label: 'Layout' },
|
||||
{ value: 'feedback', label: 'Feedback' },
|
||||
{ value: 'data-display', label: 'Data' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
export default function ComponentBrowserPanel() {
|
||||
const kits = useUIKitStore((s) => s.kits)
|
||||
const searchQuery = useUIKitStore((s) => s.searchQuery)
|
||||
const setSearchQuery = useUIKitStore((s) => s.setSearchQuery)
|
||||
const activeCategory = useUIKitStore((s) => s.activeCategory)
|
||||
const setActiveCategory = useUIKitStore((s) => s.setActiveCategory)
|
||||
const activeKitId = useUIKitStore((s) => s.activeKitId)
|
||||
const setActiveKitId = useUIKitStore((s) => s.setActiveKitId)
|
||||
const toggleBrowser = useUIKitStore((s) => s.toggleBrowser)
|
||||
const importKitAction = useUIKitStore((s) => s.importKit)
|
||||
const removeKit = useUIKitStore((s) => s.removeKit)
|
||||
|
||||
const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH)
|
||||
const [panelHeight, setPanelHeight] = useState(DEFAULT_HEIGHT)
|
||||
const [confirmDeleteKitId, setConfirmDeleteKitId] = useState<string | null>(null)
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const resizeRef = useRef<{
|
||||
edge: 'right' | 'bottom' | 'corner'
|
||||
startX: number; startY: number; startW: number; startH: number
|
||||
} | null>(null)
|
||||
|
||||
/* --- Resize --- */
|
||||
const handleResizeStart = useCallback(
|
||||
(edge: 'right' | 'bottom' | 'corner', e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
resizeRef.current = {
|
||||
edge,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startW: panelWidth,
|
||||
startH: panelHeight,
|
||||
}
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
},
|
||||
[panelWidth, panelHeight],
|
||||
)
|
||||
|
||||
const handleResizeMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return
|
||||
e.preventDefault()
|
||||
const { edge, startX, startY, startW, startH } = resizeRef.current
|
||||
const container = panelRef.current?.parentElement
|
||||
const maxW = container ? container.clientWidth - 72 : 1400
|
||||
const maxH = container ? container.clientHeight - 16 : 900
|
||||
if (edge === 'right' || edge === 'corner')
|
||||
setPanelWidth(Math.max(MIN_WIDTH, Math.min(maxW, startW + e.clientX - startX)))
|
||||
if (edge === 'bottom' || edge === 'corner')
|
||||
setPanelHeight(Math.max(MIN_HEIGHT, Math.min(maxH, startH + e.clientY - startY)))
|
||||
}, [])
|
||||
|
||||
const handleResizeEnd = useCallback((e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return
|
||||
resizeRef.current = null
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
/* --- Import --- */
|
||||
const handleImport = useCallback(async () => {
|
||||
const kit = await importKitFromFile()
|
||||
if (kit) {
|
||||
importKitAction(kit)
|
||||
}
|
||||
}, [importKitAction])
|
||||
|
||||
/* --- Export --- */
|
||||
const handleExport = useCallback(async () => {
|
||||
const doc = useDocumentStore.getState().document
|
||||
await exportKit(doc, [], doc.name ?? 'My Kit')
|
||||
}, [])
|
||||
|
||||
/* --- Delete imported kit --- */
|
||||
const handleDeleteKit = useCallback(
|
||||
(kitId: string) => {
|
||||
removeKit(kitId)
|
||||
setConfirmDeleteKitId(null)
|
||||
},
|
||||
[removeKit],
|
||||
)
|
||||
|
||||
/* --- Imported kits for delete list --- */
|
||||
const importedKits = useMemo(() => kits.filter((k) => !k.builtIn), [kits])
|
||||
|
||||
/* --- Visible categories (only show tabs that have components) --- */
|
||||
const visibleCategories = useMemo(() => {
|
||||
const categorySet = new Set<ComponentCategory>()
|
||||
const targetKits = activeKitId ? kits.filter((k) => k.id === activeKitId) : kits
|
||||
for (const kit of targetKits) {
|
||||
for (const comp of kit.components) {
|
||||
categorySet.add(comp.category)
|
||||
}
|
||||
}
|
||||
return CATEGORIES.filter((c) => c.value === null || categorySet.has(c.value))
|
||||
}, [kits, activeKitId])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="absolute left-14 top-2 z-20 flex flex-col select-none"
|
||||
style={{ width: panelWidth, height: panelHeight }}
|
||||
>
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-card/95 backdrop-blur-sm border border-border rounded-2xl shadow-2xl" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="relative h-10 flex items-center justify-between px-3 border-b border-border shrink-0">
|
||||
<span className="text-sm font-medium text-foreground">UIKit Browser</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Export kit"
|
||||
>
|
||||
<Download size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Import kit"
|
||||
>
|
||||
<Upload size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBrowser}
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Kit filter + Category tabs */}
|
||||
<div className="relative shrink-0">
|
||||
{/* Kit selector row */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-border">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Kit:</span>
|
||||
<select
|
||||
value={activeKitId ?? ''}
|
||||
onChange={(e) => setActiveKitId(e.target.value || null)}
|
||||
className="text-xs bg-transparent border border-border rounded px-1.5 py-0.5 text-foreground outline-none min-w-0"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{kits.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}{k.builtIn ? '' : ' (imported)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Imported kits list — each with individual delete */}
|
||||
{importedKits.length > 0 && (
|
||||
<div className="flex flex-col gap-0 border-b border-border">
|
||||
{importedKits.map((kit) => (
|
||||
<div
|
||||
key={kit.id}
|
||||
className="flex items-center gap-2 px-3 py-1 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-foreground truncate flex-1">{kit.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{kit.components.length} components
|
||||
</span>
|
||||
{confirmDeleteKitId === kit.id ? (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteKit(kit.id)}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteKitId(null)}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteKitId(kit.id)}
|
||||
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
title={`Delete ${kit.name}`}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border overflow-x-auto">
|
||||
{visibleCategories.map((cat) => (
|
||||
<button
|
||||
key={cat.label}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat.value)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 text-xs rounded-full whitespace-nowrap transition-colors',
|
||||
activeCategory === cat.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative shrink-0 px-3 py-2 border-b border-border">
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg px-2.5 py-1.5">
|
||||
<Search size={14} className="text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search components..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="relative flex-1 overflow-y-auto overflow-x-hidden min-h-0">
|
||||
<ComponentBrowserGrid
|
||||
kits={kits}
|
||||
searchQuery={searchQuery}
|
||||
activeCategory={activeCategory}
|
||||
activeKitId={activeKitId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resize handles */}
|
||||
<div
|
||||
className="absolute top-0 -right-1 w-2 h-full cursor-ew-resize"
|
||||
onPointerDown={(e) => handleResizeStart('right', e)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-1 left-0 w-full h-2 cursor-ns-resize"
|
||||
onPointerDown={(e) => handleResizeStart('bottom', e)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
/>
|
||||
<div
|
||||
className="absolute -bottom-1 -right-1 w-4 h-4 cursor-nwse-resize"
|
||||
onPointerDown={(e) => handleResizeStart('corner', e)}
|
||||
onPointerMove={handleResizeMove}
|
||||
onPointerUp={handleResizeEnd}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/components/panels/export-section.tsx
Normal file
79
src/components/panels/export-section.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { useState } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { exportLayerToRaster, type RasterFormat } from '@/utils/export'
|
||||
import SectionHeader from '@/components/shared/section-header'
|
||||
import DropdownSelect from '@/components/shared/dropdown-select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: '1', label: '1x' },
|
||||
{ value: '2', label: '2x' },
|
||||
{ value: '3', label: '3x' },
|
||||
]
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpeg', label: 'JPEG' },
|
||||
{ value: 'webp', label: 'WEBP' },
|
||||
]
|
||||
|
||||
interface ExportSectionProps {
|
||||
nodeId: string
|
||||
nodeName: string
|
||||
}
|
||||
|
||||
export default function ExportSection({ nodeId, nodeName }: ExportSectionProps) {
|
||||
const [scale, setScale] = useState('1')
|
||||
const [format, setFormat] = useState('png')
|
||||
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
|
||||
|
||||
const handleExport = () => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
// Collect all descendant IDs for this node
|
||||
const { getFlatNodes, isDescendantOf } = useDocumentStore.getState()
|
||||
const allNodes = getFlatNodes()
|
||||
const descendantIds = new Set<string>()
|
||||
for (const n of allNodes) {
|
||||
if (n.id !== nodeId && isDescendantOf(n.id, nodeId)) {
|
||||
descendantIds.add(n.id)
|
||||
}
|
||||
}
|
||||
|
||||
const ext = format === 'jpeg' ? 'jpg' : format
|
||||
exportLayerToRaster(fabricCanvas, nodeId, descendantIds, {
|
||||
format: format as RasterFormat,
|
||||
multiplier: Number(scale),
|
||||
filename: `${nodeName}.${ext}`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Export" />
|
||||
<div className="flex gap-1.5">
|
||||
<DropdownSelect
|
||||
value={scale}
|
||||
options={SCALE_OPTIONS}
|
||||
onChange={setScale}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DropdownSelect
|
||||
value={format}
|
||||
options={FORMAT_OPTIONS}
|
||||
onChange={setFormat}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={handleExport}
|
||||
>
|
||||
Export layer
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import {
|
|||
Group,
|
||||
Lock,
|
||||
EyeOff,
|
||||
Component,
|
||||
Unlink,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LayerContextMenuProps {
|
||||
|
|
@ -12,6 +14,9 @@ interface LayerContextMenuProps {
|
|||
y: number
|
||||
nodeId: string
|
||||
canGroup: boolean
|
||||
canCreateComponent: boolean
|
||||
isReusable: boolean
|
||||
isInstance: boolean
|
||||
onAction: (action: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
|
@ -20,6 +25,9 @@ const MENU_ITEMS = [
|
|||
{ action: 'duplicate', label: 'Duplicate', icon: Copy },
|
||||
{ action: 'delete', label: 'Delete', icon: Trash2 },
|
||||
{ action: 'group', label: 'Group Selection', icon: Group, requireGroup: true },
|
||||
{ action: 'make-component', label: 'Create Component', icon: Component, requireCreateComponent: true },
|
||||
{ action: 'detach-component', label: 'Detach Component', icon: Unlink, requireReusable: true },
|
||||
{ action: 'detach-component', label: 'Detach Instance', icon: Unlink, requireInstance: true },
|
||||
{ action: 'lock', label: 'Toggle Lock', icon: Lock },
|
||||
{ action: 'hide', label: 'Toggle Visibility', icon: EyeOff },
|
||||
]
|
||||
|
|
@ -28,6 +36,9 @@ export default function LayerContextMenu({
|
|||
x,
|
||||
y,
|
||||
canGroup,
|
||||
canCreateComponent,
|
||||
isReusable,
|
||||
isInstance,
|
||||
onAction,
|
||||
onClose,
|
||||
}: LayerContextMenuProps) {
|
||||
|
|
@ -57,7 +68,11 @@ export default function LayerContextMenu({
|
|||
style={{ left: x, top: y }}
|
||||
>
|
||||
{MENU_ITEMS.filter(
|
||||
(item) => !item.requireGroup || canGroup,
|
||||
(item) =>
|
||||
(!item.requireGroup || canGroup) &&
|
||||
(!('requireCreateComponent' in item) || canCreateComponent) &&
|
||||
(!('requireReusable' in item) || isReusable) &&
|
||||
(!('requireInstance' in item) || isInstance),
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.action}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ImageIcon,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Diamond,
|
||||
} from 'lucide-react'
|
||||
import type { PenNodeType } from '@/types/pen'
|
||||
|
||||
|
|
@ -44,6 +45,8 @@ interface LayerItemProps {
|
|||
locked: boolean
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
isReusable: boolean
|
||||
isInstance: boolean
|
||||
dropPosition: DropPosition
|
||||
onSelect: (id: string) => void
|
||||
onRename: (id: string, name: string) => void
|
||||
|
|
@ -66,6 +69,8 @@ export default function LayerItem({
|
|||
locked,
|
||||
hasChildren,
|
||||
expanded,
|
||||
isReusable,
|
||||
isInstance,
|
||||
dropPosition,
|
||||
onSelect,
|
||||
onRename,
|
||||
|
|
@ -80,7 +85,7 @@ export default function LayerItem({
|
|||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(name)
|
||||
|
||||
const Icon = TYPE_ICONS[type] ?? Square
|
||||
const Icon = isReusable || isInstance ? Diamond : (TYPE_ICONS[type] ?? Square)
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
setEditName(name)
|
||||
|
|
@ -113,15 +118,23 @@ export default function LayerItem({
|
|||
dropPosition === 'inside' ? 'ring-2 ring-inset ring-blue-500 bg-blue-500/10' : ''
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative" data-layer-id={id}>
|
||||
{dropPosition === 'above' && (
|
||||
<div className="absolute top-0 left-2 right-2 h-0.5 bg-blue-500 rounded-full z-10" />
|
||||
)}
|
||||
<div
|
||||
className={`group/layer flex items-center h-7 px-1 gap-1 cursor-pointer rounded text-xs transition-colors ${
|
||||
selected
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
? isReusable
|
||||
? 'bg-purple-500/15 text-purple-400'
|
||||
: isInstance
|
||||
? 'bg-[#9281f7]/10 text-[#9281f7]'
|
||||
: 'bg-primary/15 text-primary'
|
||||
: isReusable
|
||||
? 'text-purple-400 hover:bg-purple-500/10'
|
||||
: isInstance
|
||||
? 'text-[#9281f7] hover:bg-[#9281f7]/10'
|
||||
: 'text-muted-foreground hover:bg-accent/50'
|
||||
} ${!visible ? 'opacity-40' : ''} ${dropInsideHighlight}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
||||
onClick={() => onSelect(id)}
|
||||
|
|
@ -146,7 +159,7 @@ export default function LayerItem({
|
|||
<span className="shrink-0 w-3" />
|
||||
)}
|
||||
|
||||
<Icon size={12} className="shrink-0 opacity-60" />
|
||||
<Icon size={12} className={`shrink-0 ${isReusable ? 'text-purple-400' : isInstance ? 'text-[#9281f7]' : 'opacity-60'}`} />
|
||||
|
||||
{isEditing ? (
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useDocumentStore, findNodeInTree } from '@/stores/document-store'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { setSkipNextDepthResolve } from '@/canvas/use-canvas-selection'
|
||||
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
||||
import type { PenNode } from '@/types/pen'
|
||||
import LayerItem from './layer-item'
|
||||
|
|
@ -15,6 +16,28 @@ interface DragState {
|
|||
dropPosition: DropPosition
|
||||
}
|
||||
|
||||
function isNodeReusable(node: PenNode, parentReusable: boolean): boolean {
|
||||
if (parentReusable) return true
|
||||
return 'reusable' in node && node.reusable === true
|
||||
}
|
||||
|
||||
/** Get effective children for a node, resolving RefNode instances. */
|
||||
function getEffectiveChildren(
|
||||
node: PenNode,
|
||||
allChildren: PenNode[],
|
||||
): PenNode[] | null {
|
||||
if (node.type === 'ref') {
|
||||
const component = findNodeInTree(allChildren, node.ref)
|
||||
if (component && 'children' in component && component.children?.length) {
|
||||
return component.children
|
||||
}
|
||||
return null
|
||||
}
|
||||
return 'children' in node && node.children && node.children.length > 0
|
||||
? node.children
|
||||
: null
|
||||
}
|
||||
|
||||
function renderLayerTree(
|
||||
nodes: PenNode[],
|
||||
depth: number,
|
||||
|
|
@ -33,14 +56,16 @@ function renderLayerTree(
|
|||
dragOverId: string | null,
|
||||
dropPosition: DropPosition,
|
||||
collapsedIds: Set<string>,
|
||||
allChildren: PenNode[],
|
||||
parentReusable = false,
|
||||
parentIsInstance = false,
|
||||
) {
|
||||
return [...nodes].reverse().map((node) => {
|
||||
const nodeChildren =
|
||||
'children' in node && node.children && node.children.length > 0
|
||||
? node.children
|
||||
: null
|
||||
const nodeChildren = getEffectiveChildren(node, allChildren)
|
||||
const isExpanded = !collapsedIds.has(node.id)
|
||||
const isDropTarget = dragOverId === node.id
|
||||
const isInstance = node.type === 'ref' || parentIsInstance
|
||||
const reusable = isNodeReusable(node, parentReusable)
|
||||
|
||||
return (
|
||||
<div key={node.id}>
|
||||
|
|
@ -54,6 +79,8 @@ function renderLayerTree(
|
|||
locked={node.locked === true}
|
||||
hasChildren={nodeChildren !== null}
|
||||
expanded={isExpanded}
|
||||
isReusable={reusable}
|
||||
isInstance={isInstance}
|
||||
dropPosition={isDropTarget ? dropPosition : null}
|
||||
{...handlers}
|
||||
/>
|
||||
|
|
@ -67,12 +94,29 @@ function renderLayerTree(
|
|||
dragOverId,
|
||||
dropPosition,
|
||||
collapsedIds,
|
||||
allChildren,
|
||||
reusable,
|
||||
isInstance,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function collectCollapsibleNodeIds(
|
||||
nodes: PenNode[],
|
||||
allChildren: PenNode[],
|
||||
result: Set<string> = new Set(),
|
||||
): Set<string> {
|
||||
for (const node of nodes) {
|
||||
const nodeChildren = getEffectiveChildren(node, allChildren)
|
||||
if (!nodeChildren) continue
|
||||
result.add(node.id)
|
||||
collectCollapsibleNodeIds(nodeChildren, allChildren, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function LayerPanel() {
|
||||
const children = useDocumentStore((s) => s.document.children)
|
||||
const updateNode = useDocumentStore((s) => s.updateNode)
|
||||
|
|
@ -102,7 +146,65 @@ export default function LayerPanel() {
|
|||
})
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [dropPosition, setDropPosition] = useState<DropPosition>(null)
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(
|
||||
() => collectCollapsibleNodeIds(children, children),
|
||||
)
|
||||
const knownCollapsibleIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const currentCollapsibleIds = collectCollapsibleNodeIds(children, children)
|
||||
const known = knownCollapsibleIdsRef.current
|
||||
|
||||
setCollapsedIds((prev) => {
|
||||
const next = new Set<string>()
|
||||
for (const id of currentCollapsibleIds) {
|
||||
const isNewNode = !known.has(id)
|
||||
if (isNewNode || prev.has(id)) {
|
||||
next.add(id)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
knownCollapsibleIdsRef.current = currentCollapsibleIds
|
||||
}, [children])
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-expand ancestors when selection changes (e.g. child selected on canvas)
|
||||
useEffect(() => {
|
||||
if (selectedIds.length === 0) return
|
||||
const ancestorIds = new Set<string>()
|
||||
for (const id of selectedIds) {
|
||||
let current = getParentOf(id)
|
||||
while (current) {
|
||||
ancestorIds.add(current.id)
|
||||
current = getParentOf(current.id)
|
||||
}
|
||||
}
|
||||
if (ancestorIds.size === 0) return
|
||||
|
||||
setCollapsedIds((prev) => {
|
||||
let changed = false
|
||||
for (const aid of ancestorIds) {
|
||||
if (prev.has(aid)) { changed = true; break }
|
||||
}
|
||||
if (!changed) return prev
|
||||
const next = new Set(prev)
|
||||
for (const aid of ancestorIds) next.delete(aid)
|
||||
return next
|
||||
})
|
||||
|
||||
// Scroll the selected item into view after DOM updates
|
||||
requestAnimationFrame(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container) return
|
||||
const el = container.querySelector(`[data-layer-id="${selectedIds[0]}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
}, [selectedIds, getParentOf])
|
||||
|
||||
const handleToggleExpand = useCallback((id: string) => {
|
||||
setCollapsedIds((prev) => {
|
||||
|
|
@ -125,6 +227,7 @@ export default function LayerPanel() {
|
|||
(o) => (o as FabricObjectWithPenId).penNodeId === id,
|
||||
)
|
||||
if (target) {
|
||||
setSkipNextDepthResolve()
|
||||
fabricCanvas.setActiveObject(target)
|
||||
fabricCanvas.requestRenderAll()
|
||||
}
|
||||
|
|
@ -214,6 +317,9 @@ export default function LayerPanel() {
|
|||
setDropPosition(null)
|
||||
}, [children, getParentOf, moveNode])
|
||||
|
||||
const makeReusable = useDocumentStore((s) => s.makeReusable)
|
||||
const detachComponent = useDocumentStore((s) => s.detachComponent)
|
||||
|
||||
const handleContextAction = useCallback(
|
||||
(action: string) => {
|
||||
if (!contextMenu) return
|
||||
|
|
@ -239,6 +345,12 @@ export default function LayerPanel() {
|
|||
case 'hide':
|
||||
toggleVisibility(nodeId)
|
||||
break
|
||||
case 'make-component':
|
||||
makeReusable(nodeId)
|
||||
break
|
||||
case 'detach-component':
|
||||
detachComponent(nodeId)
|
||||
break
|
||||
}
|
||||
setContextMenu(null)
|
||||
},
|
||||
|
|
@ -251,6 +363,8 @@ export default function LayerPanel() {
|
|||
toggleLock,
|
||||
toggleVisibility,
|
||||
setSelection,
|
||||
makeReusable,
|
||||
detachComponent,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -273,7 +387,7 @@ export default function LayerPanel() {
|
|||
Layers
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto py-1 px-1">
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto py-1 px-1">
|
||||
{children.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center mt-4 px-2">
|
||||
No layers yet. Use the toolbar to draw shapes.
|
||||
|
|
@ -287,20 +401,34 @@ export default function LayerPanel() {
|
|||
dragOverId,
|
||||
dropPosition,
|
||||
collapsedIds,
|
||||
children,
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{contextMenu && (
|
||||
<LayerContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
nodeId={contextMenu.nodeId}
|
||||
canGroup={selectedIds.length >= 2}
|
||||
onAction={handleContextAction}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{contextMenu && (() => {
|
||||
const contextNode = getNodeById(contextMenu.nodeId)
|
||||
const isContainer = contextNode
|
||||
? contextNode.type === 'frame' || contextNode.type === 'group' || contextNode.type === 'rectangle'
|
||||
: false
|
||||
const nodeIsReusable = contextNode
|
||||
? 'reusable' in contextNode && contextNode.reusable === true
|
||||
: false
|
||||
const nodeIsInstance = contextNode?.type === 'ref'
|
||||
return (
|
||||
<LayerContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
nodeId={contextMenu.nodeId}
|
||||
canGroup={selectedIds.length >= 2}
|
||||
canCreateComponent={isContainer && !nodeIsReusable}
|
||||
isReusable={nodeIsReusable}
|
||||
isInstance={nodeIsInstance}
|
||||
onAction={handleContextAction}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import NumberInput from '@/components/shared/number-input'
|
||||
import DropdownSelect from '@/components/shared/dropdown-select'
|
||||
import VariablePicker from '@/components/shared/variable-picker'
|
||||
import { isVariableRef } from '@/variables/resolve-variables'
|
||||
import type { PenNode, ContainerProps } from '@/types/pen'
|
||||
import type { PenNode, ContainerProps, SizingBehavior } from '@/types/pen'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Rows3,
|
||||
Columns3,
|
||||
Rows3,
|
||||
LayoutGrid,
|
||||
AlignStartHorizontal,
|
||||
AlignCenterHorizontal,
|
||||
AlignEndHorizontal,
|
||||
AlignStartVertical,
|
||||
AlignCenterVertical,
|
||||
AlignEndVertical,
|
||||
Settings,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LayoutSectionProps {
|
||||
|
|
@ -21,19 +15,34 @@ interface LayoutSectionProps {
|
|||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}
|
||||
|
||||
const JUSTIFY_OPTIONS = [
|
||||
{ value: 'start', label: 'Start' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'end', label: 'End' },
|
||||
{ value: 'space_between', label: 'Between' },
|
||||
{ value: 'space_around', label: 'Around' },
|
||||
]
|
||||
const POSITIONS = ['start', 'center', 'end'] as const
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
{ value: 'start', label: 'Start' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'end', label: 'End' },
|
||||
]
|
||||
type GapMode = 'numeric' | 'space_between' | 'space_around'
|
||||
type PaddingMode = 'single' | 'axis' | 'individual'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Padding Icons (small SVG indicators for V/H padding)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PadVIcon = (
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
|
||||
<rect x="2.5" y="3.5" width="7" height="5" strokeWidth="1.2" rx="0.5" />
|
||||
<line x1="4" y1="1" x2="8" y2="1" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="4" y1="11" x2="8" y2="11" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PadHIcon = (
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor">
|
||||
<rect x="2.5" y="2.5" width="7" height="7" strokeWidth="1.2" rx="0.5" />
|
||||
<line x1="1" y1="4" x2="1" y2="8" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="11" y1="4" x2="11" y2="8" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToggleButton
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
|
|
@ -52,7 +61,7 @@ function ToggleButton({
|
|||
title={title}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-6 w-6 flex items-center justify-center rounded transition-colors',
|
||||
'h-7 w-7 flex items-center justify-center rounded transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
|
|
@ -63,140 +72,701 @@ function ToggleButton({
|
|||
)
|
||||
}
|
||||
|
||||
export default function LayoutSection({ node, onUpdate }: LayoutSectionProps) {
|
||||
const layout = node.layout ?? 'none'
|
||||
const rawGap = node.gap
|
||||
const rawPadding = node.padding
|
||||
const gapIsBound = typeof rawGap === 'string' && isVariableRef(rawGap)
|
||||
const paddingIsBound = typeof rawPadding === 'string' && isVariableRef(rawPadding)
|
||||
const gap = typeof rawGap === 'number' ? rawGap : 0
|
||||
const padding = typeof rawPadding === 'number'
|
||||
? rawPadding
|
||||
: Array.isArray(rawPadding)
|
||||
? rawPadding[0]
|
||||
: 0
|
||||
const justifyContent = node.justifyContent ?? 'start'
|
||||
const alignItems = node.alignItems ?? 'start'
|
||||
const hasLayout = layout !== 'none'
|
||||
// ---------------------------------------------------------------------------
|
||||
// RadioCircle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RadioCircle({
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
selected: boolean
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-[14px] h-[14px] rounded-full border-[1.5px] flex items-center justify-center shrink-0 transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40',
|
||||
)}
|
||||
>
|
||||
{selected && <div className="w-2 h-2 rounded-full bg-primary" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AlignmentGrid — 3×3 interactive alignment picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AlignmentGrid({
|
||||
layout,
|
||||
justifyContent,
|
||||
alignItems,
|
||||
isSpaceMode,
|
||||
onUpdate,
|
||||
}: {
|
||||
layout: 'none' | 'vertical' | 'horizontal'
|
||||
justifyContent: string
|
||||
alignItems: string
|
||||
isSpaceMode: boolean
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}) {
|
||||
const isFreedom = layout === 'none'
|
||||
const isVertical = layout === 'vertical'
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground tracking-wider">
|
||||
Layout
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-[3px] p-2 bg-secondary rounded">
|
||||
{[0, 1, 2].map((row) =>
|
||||
[0, 1, 2].map((col) => {
|
||||
const rowPos = POSITIONS[row]
|
||||
const colPos = POSITIONS[col]
|
||||
const cellJustify = isVertical ? rowPos : colPos
|
||||
const cellAlign = isVertical ? colPos : rowPos
|
||||
const isActive =
|
||||
!isFreedom &&
|
||||
!isSpaceMode &&
|
||||
justifyContent === cellJustify &&
|
||||
alignItems === cellAlign
|
||||
const cellCrossPos = isVertical ? colPos : rowPos
|
||||
const isOnActiveCross =
|
||||
isSpaceMode && cellCrossPos === alignItems
|
||||
|
||||
{/* Layout direction */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Dir</span>
|
||||
<div className="flex gap-0.5">
|
||||
<ToggleButton
|
||||
active={layout === 'none'}
|
||||
onClick={() => onUpdate({ layout: 'none' } as Partial<PenNode>)}
|
||||
title="No layout"
|
||||
>
|
||||
<LayoutGrid className="w-3 h-3" />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={layout === 'vertical'}
|
||||
onClick={() => onUpdate({ layout: 'vertical' } as Partial<PenNode>)}
|
||||
title="Vertical layout"
|
||||
>
|
||||
<Rows3 className="w-3 h-3" />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={layout === 'horizontal'}
|
||||
onClick={() => onUpdate({ layout: 'horizontal' } as Partial<PenNode>)}
|
||||
title="Horizontal layout"
|
||||
>
|
||||
<Columns3 className="w-3 h-3" />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasLayout && (
|
||||
<>
|
||||
{/* Gap & Padding */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
{gapIsBound ? (
|
||||
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
|
||||
{String(rawGap)}
|
||||
</div>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Gap"
|
||||
value={gap}
|
||||
onChange={(v) => onUpdate({ gap: v } as Partial<PenNode>)}
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<VariablePicker
|
||||
type="number"
|
||||
currentValue={gapIsBound ? String(rawGap) : undefined}
|
||||
onBind={(ref) => onUpdate({ gap: ref as unknown as number } as Partial<PenNode>)}
|
||||
onUnbind={(val) => onUpdate({ gap: Number(val) } as Partial<PenNode>)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
{paddingIsBound ? (
|
||||
<div className="h-6 flex items-center px-2 bg-secondary rounded text-[11px] font-mono text-muted-foreground">
|
||||
{String(rawPadding)}
|
||||
</div>
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Pad"
|
||||
value={padding}
|
||||
onChange={(v) => onUpdate({ padding: v } as Partial<PenNode>)}
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<VariablePicker
|
||||
type="number"
|
||||
currentValue={paddingIsBound ? String(rawPadding) : undefined}
|
||||
onBind={(ref) => onUpdate({ padding: ref as unknown as number } as Partial<PenNode>)}
|
||||
onUnbind={(val) => onUpdate({ padding: Number(val) } as Partial<PenNode>)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Justify Content */}
|
||||
<DropdownSelect
|
||||
label="Justify"
|
||||
value={justifyContent}
|
||||
options={JUSTIFY_OPTIONS}
|
||||
onChange={(v) =>
|
||||
onUpdate({ justifyContent: v } as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Align Items */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground w-8 shrink-0">Align</span>
|
||||
<div className="flex gap-0.5">
|
||||
{ALIGN_OPTIONS.map((opt) => {
|
||||
const icons = layout === 'horizontal'
|
||||
? { start: AlignStartVertical, center: AlignCenterVertical, end: AlignEndVertical }
|
||||
: { start: AlignStartHorizontal, center: AlignCenterHorizontal, end: AlignEndHorizontal }
|
||||
const Icon = icons[opt.value as keyof typeof icons]
|
||||
return (
|
||||
<ToggleButton
|
||||
key={opt.value}
|
||||
active={alignItems === opt.value}
|
||||
onClick={() => onUpdate({ alignItems: opt.value } as Partial<PenNode>)}
|
||||
title={`Align ${opt.label}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
</ToggleButton>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
return (
|
||||
<button
|
||||
key={`${row}-${col}`}
|
||||
type="button"
|
||||
disabled={isFreedom}
|
||||
className={cn(
|
||||
'w-7 h-5 rounded-[2px] flex items-center justify-center transition-colors',
|
||||
isFreedom && 'cursor-default',
|
||||
!isFreedom && 'cursor-pointer hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isFreedom) return
|
||||
if (isSpaceMode) {
|
||||
onUpdate({
|
||||
alignItems: cellAlign,
|
||||
} as Partial<PenNode>)
|
||||
} else {
|
||||
onUpdate({
|
||||
justifyContent: cellJustify,
|
||||
alignItems: cellAlign,
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isFreedom ? (
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/30" />
|
||||
) : isSpaceMode && isOnActiveCross ? (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[1px] bg-primary',
|
||||
isVertical
|
||||
? 'w-[10px] h-[2px]'
|
||||
: 'w-[2px] h-[10px]',
|
||||
)}
|
||||
/>
|
||||
) : isActive ? (
|
||||
<div className="w-2.5 h-2.5 rounded-[2px] bg-primary" />
|
||||
) : (
|
||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground/40" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GapSection — Radio: Numeric / Space Between / Space Around
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GapSection({
|
||||
gap,
|
||||
gapMode,
|
||||
onGapModeChange,
|
||||
onUpdate,
|
||||
}: {
|
||||
gap: number
|
||||
gapMode: GapMode
|
||||
onGapModeChange: (mode: GapMode) => void
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onGapModeChange('numeric')}
|
||||
>
|
||||
<RadioCircle selected={gapMode === 'numeric'} />
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<NumberInput
|
||||
value={gap}
|
||||
onChange={(v) =>
|
||||
onUpdate({ gap: v } as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onGapModeChange('space_between')}
|
||||
>
|
||||
<RadioCircle selected={gapMode === 'space_between'} />
|
||||
<span className="text-[10px] text-muted-foreground select-none">
|
||||
Space Between
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => onGapModeChange('space_around')}
|
||||
>
|
||||
<RadioCircle selected={gapMode === 'space_around'} />
|
||||
<span className="text-[10px] text-muted-foreground select-none">
|
||||
Space Around
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaddingSection — Uniform / V-H / T-R-B-L with gear popover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parsePaddingValues(
|
||||
padding:
|
||||
| number
|
||||
| [number, number]
|
||||
| [number, number, number, number]
|
||||
| string
|
||||
| undefined,
|
||||
): { mode: PaddingMode; values: [number, number, number, number] } {
|
||||
if (typeof padding === 'string' || padding === undefined) {
|
||||
return { mode: 'single', values: [0, 0, 0, 0] }
|
||||
}
|
||||
if (typeof padding === 'number') {
|
||||
return {
|
||||
mode: 'single',
|
||||
values: [padding, padding, padding, padding],
|
||||
}
|
||||
}
|
||||
if (padding.length === 2) {
|
||||
return {
|
||||
mode: 'axis',
|
||||
values: [padding[0], padding[1], padding[0], padding[1]],
|
||||
}
|
||||
}
|
||||
if (padding[0] === padding[2] && padding[1] === padding[3]) {
|
||||
return {
|
||||
mode: 'axis',
|
||||
values: [padding[0], padding[1], padding[2], padding[3]],
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'individual',
|
||||
values: [padding[0], padding[1], padding[2], padding[3]],
|
||||
}
|
||||
}
|
||||
|
||||
function PaddingSection({
|
||||
padding,
|
||||
onUpdate,
|
||||
}: {
|
||||
padding:
|
||||
| number
|
||||
| [number, number]
|
||||
| [number, number, number, number]
|
||||
| string
|
||||
| undefined
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}) {
|
||||
const parsed = parsePaddingValues(padding)
|
||||
const [mode, setMode] = useState<PaddingMode>(parsed.mode)
|
||||
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setMode(parsePaddingValues(padding).mode)
|
||||
}, [padding])
|
||||
|
||||
useEffect(() => {
|
||||
if (!popoverOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
popoverRef.current &&
|
||||
!popoverRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setPopoverOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [popoverOpen])
|
||||
|
||||
const handleModeChange = (newMode: PaddingMode) => {
|
||||
setMode(newMode)
|
||||
setPopoverOpen(false)
|
||||
const vals = parsed.values
|
||||
switch (newMode) {
|
||||
case 'single':
|
||||
onUpdate({ padding: vals[0] } as Partial<PenNode>)
|
||||
break
|
||||
case 'axis':
|
||||
onUpdate({
|
||||
padding: [vals[0], vals[1]],
|
||||
} as Partial<PenNode>)
|
||||
break
|
||||
case 'individual':
|
||||
onUpdate({
|
||||
padding: [vals[0], vals[1], vals[2], vals[3]],
|
||||
} as Partial<PenNode>)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const MODES = [
|
||||
{ value: 'single' as const, label: 'One value for all sides' },
|
||||
{ value: 'axis' as const, label: 'Horizontal/Vertical' },
|
||||
{ value: 'individual' as const, label: 'Top/Right/Bottom/Left' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{/* Label row: "Padding" left, gear right */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Padding
|
||||
</span>
|
||||
<div ref={popoverRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
title="Padding mode"
|
||||
onClick={() => setPopoverOpen(!popoverOpen)}
|
||||
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{popoverOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-md p-3 min-w-[190px]">
|
||||
<div className="text-[12px] font-medium mb-3 text-foreground">Padding Values</div>
|
||||
<div className="space-y-2.5">
|
||||
{MODES.map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => handleModeChange(opt.value)}
|
||||
>
|
||||
<RadioCircle selected={mode === opt.value} />
|
||||
<span className="text-[12px] text-foreground leading-none">{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding inputs */}
|
||||
{mode === 'single' && (
|
||||
<NumberInput
|
||||
icon={PadVIcon}
|
||||
value={parsed.values[0]}
|
||||
onChange={(v) =>
|
||||
onUpdate({ padding: v } as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'axis' && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
icon={PadHIcon}
|
||||
value={parsed.values[1]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [parsed.values[0], v],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
icon={PadVIcon}
|
||||
value={parsed.values[0]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [v, parsed.values[1]],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'individual' && (
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
label="T"
|
||||
value={parsed.values[0]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [
|
||||
v,
|
||||
parsed.values[1],
|
||||
parsed.values[2],
|
||||
parsed.values[3],
|
||||
],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="R"
|
||||
value={parsed.values[1]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [
|
||||
parsed.values[0],
|
||||
v,
|
||||
parsed.values[2],
|
||||
parsed.values[3],
|
||||
],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="B"
|
||||
value={parsed.values[2]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [
|
||||
parsed.values[0],
|
||||
parsed.values[1],
|
||||
v,
|
||||
parsed.values[3],
|
||||
],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="L"
|
||||
value={parsed.values[3]}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
padding: [
|
||||
parsed.values[0],
|
||||
parsed.values[1],
|
||||
parsed.values[2],
|
||||
v,
|
||||
],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SizingCheckbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SizingCheckbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer group">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
|
||||
checked
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground select-none">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SizingCheckboxes — Fill / Hug per axis + Clip Content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractNumericSize(
|
||||
value: SizingBehavior | undefined,
|
||||
): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/\((\d+)\)/)
|
||||
if (match) return parseInt(match[1], 10)
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
function SizingCheckboxes({
|
||||
node,
|
||||
onUpdate,
|
||||
}: {
|
||||
node: PenNode & ContainerProps
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}) {
|
||||
const widthStr =
|
||||
typeof node.width === 'string' ? node.width : ''
|
||||
const heightStr =
|
||||
typeof node.height === 'string' ? node.height : ''
|
||||
const fillWidth = widthStr.startsWith('fill_container')
|
||||
const fillHeight = heightStr.startsWith('fill_container')
|
||||
const hugWidth = widthStr.startsWith('fit_content')
|
||||
const hugHeight = heightStr.startsWith('fit_content')
|
||||
const clipContent = node.clipContent === true
|
||||
const fallbackW = extractNumericSize(node.width)
|
||||
const fallbackH = extractNumericSize(node.height)
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="grid grid-cols-2 gap-y-1.5">
|
||||
<SizingCheckbox
|
||||
label="Fill Width"
|
||||
checked={fillWidth}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
width: v ? 'fill_container' : fallbackW,
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
<SizingCheckbox
|
||||
label="Fill Height"
|
||||
checked={fillHeight}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
height: v ? 'fill_container' : fallbackH,
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
<SizingCheckbox
|
||||
label="Hug Width"
|
||||
checked={hugWidth}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
width: v ? 'fit_content' : fallbackW,
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
<SizingCheckbox
|
||||
label="Hug Height"
|
||||
checked={hugHeight}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
height: v ? 'fit_content' : fallbackH,
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SizingCheckbox
|
||||
label="Clip Content"
|
||||
checked={clipContent}
|
||||
onChange={(v) =>
|
||||
onUpdate({ clipContent: v } as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main LayoutSection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function LayoutSection({
|
||||
node,
|
||||
onUpdate,
|
||||
}: LayoutSectionProps) {
|
||||
const layout = node.layout ?? 'none'
|
||||
const hasLayout = layout !== 'none'
|
||||
|
||||
const justifyContent = node.justifyContent ?? 'start'
|
||||
const alignItems = node.alignItems ?? 'start'
|
||||
const rawGap = node.gap
|
||||
const gap = typeof rawGap === 'number' ? rawGap : 0
|
||||
|
||||
const gapMode: GapMode =
|
||||
justifyContent === 'space_between'
|
||||
? 'space_between'
|
||||
: justifyContent === 'space_around'
|
||||
? 'space_around'
|
||||
: 'numeric'
|
||||
|
||||
const isSpaceMode =
|
||||
gapMode === 'space_between' || gapMode === 'space_around'
|
||||
|
||||
const handleGapModeChange = (mode: GapMode) => {
|
||||
switch (mode) {
|
||||
case 'numeric':
|
||||
onUpdate({
|
||||
justifyContent: 'start',
|
||||
} as Partial<PenNode>)
|
||||
break
|
||||
case 'space_between':
|
||||
onUpdate({
|
||||
justifyContent: 'space_between',
|
||||
} as Partial<PenNode>)
|
||||
break
|
||||
case 'space_around':
|
||||
onUpdate({
|
||||
justifyContent: 'space_around',
|
||||
} as Partial<PenNode>)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const width =
|
||||
typeof node.width === 'number' ? node.width : undefined
|
||||
const height =
|
||||
typeof node.height === 'number' ? node.height : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
Flex Layout
|
||||
</span>
|
||||
|
||||
{/* Direction row — no label, just buttons */}
|
||||
<div className="flex jusfity-between gap-0.5">
|
||||
<ToggleButton
|
||||
active={layout === 'none'}
|
||||
onClick={() =>
|
||||
onUpdate({ layout: 'none' } as Partial<PenNode>)
|
||||
}
|
||||
title="Freedom (no layout)"
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={layout === 'vertical'}
|
||||
onClick={() =>
|
||||
onUpdate({ layout: 'vertical' } as Partial<PenNode>)
|
||||
}
|
||||
title="Vertical layout"
|
||||
>
|
||||
<Rows3 className="w-3.5 h-3.5" />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
active={layout === 'horizontal'}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
layout: 'horizontal',
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
title="Horizontal layout"
|
||||
>
|
||||
<Columns3 className="w-3.5 h-3.5" />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
|
||||
{/* Alignment + Gap side by side */}
|
||||
{hasLayout && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
{/* Left: Alignment */}
|
||||
<div className="w-[160px]">
|
||||
<span className="text-[10px] w-full text-muted-foreground mb-1.5 block">
|
||||
Alignment
|
||||
</span>
|
||||
<AlignmentGrid
|
||||
layout={layout}
|
||||
justifyContent={justifyContent}
|
||||
alignItems={alignItems}
|
||||
isSpaceMode={isSpaceMode}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
{/* Right: Gap */}
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1.5 block">
|
||||
Gap
|
||||
</span>
|
||||
<GapSection
|
||||
gap={gap}
|
||||
gapMode={gapMode}
|
||||
onGapModeChange={handleGapModeChange}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Padding */}
|
||||
<PaddingSection
|
||||
padding={node.padding}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dimensions */}
|
||||
{(width !== undefined || height !== undefined) && (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1.5 block">
|
||||
Dimensions
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{width !== undefined && (
|
||||
<NumberInput
|
||||
label="W"
|
||||
value={Math.round(width)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ width: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
{height !== undefined && (
|
||||
<NumberInput
|
||||
label="H"
|
||||
value={Math.round(height)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ height: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sizing checkboxes */}
|
||||
<SizingCheckboxes node={node} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,262 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvas-store'
|
||||
import { useDocumentStore } from '@/stores/document-store'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { PenNode, ContainerProps } from '@/types/pen'
|
||||
import type { PenNode, ContainerProps, RefNode } from '@/types/pen'
|
||||
import { Component, Diamond, ArrowUpRight, Unlink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
||||
import SizeSection from './size-section'
|
||||
import LayoutSection from './layout-section'
|
||||
import FillSection from './fill-section'
|
||||
import StrokeSection from './stroke-section'
|
||||
import AppearanceSection from './appearance-section'
|
||||
import TextSection from './text-section'
|
||||
import TextLayoutSection from './text-layout-section'
|
||||
import EffectsSection from './effects-section'
|
||||
import ExportSection from './export-section'
|
||||
|
||||
/** Properties stored directly on the RefNode (instance-level), not as overrides. */
|
||||
const INSTANCE_DIRECT_PROPS = new Set([
|
||||
'x', 'y', 'width', 'height', 'name', 'visible', 'locked', 'rotation', 'opacity', 'flipX', 'flipY', 'enabled', 'theme',
|
||||
])
|
||||
|
||||
export default function PropertyPanel() {
|
||||
const activeId = useCanvasStore((s) => s.selection.activeId)
|
||||
const setSelection = useCanvasStore((s) => s.setSelection)
|
||||
const fabricCanvas = useCanvasStore((s) => s.fabricCanvas)
|
||||
const children = useDocumentStore((s) => s.document.children)
|
||||
const getNodeById = useDocumentStore((s) => s.getNodeById)
|
||||
const updateNode = useDocumentStore((s) => s.updateNode)
|
||||
const makeReusable = useDocumentStore((s) => s.makeReusable)
|
||||
const detachComponent = useDocumentStore((s) => s.detachComponent)
|
||||
|
||||
// Subscribe to `children` so we re-render when nodes change
|
||||
void children
|
||||
const node = activeId ? getNodeById(activeId) : undefined
|
||||
|
||||
const handleUpdate = (updates: Partial<PenNode>) => {
|
||||
if (activeId) {
|
||||
updateNode(activeId, updates)
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
return null
|
||||
}
|
||||
|
||||
const hasLayout =
|
||||
node.type === 'frame' || node.type === 'group' || node.type === 'rectangle'
|
||||
const hasFill =
|
||||
node.type !== 'line' && node.type !== 'ref'
|
||||
const hasStroke = node.type !== 'ref'
|
||||
const nodeIsReusable = 'reusable' in node && node.reusable === true
|
||||
const nodeIsInstance = node.type === 'ref'
|
||||
|
||||
// For RefNodes, resolve the referenced component to get visual properties.
|
||||
// The display node merges: component base → instance overrides → RefNode position/meta.
|
||||
let displayNode = node
|
||||
if (nodeIsInstance) {
|
||||
const refNode = node as RefNode
|
||||
const component = getNodeById(refNode.ref)
|
||||
if (component) {
|
||||
const topOverrides = refNode.descendants?.[refNode.ref] ?? {}
|
||||
const merged: Record<string, unknown> = { ...component, ...topOverrides }
|
||||
// Apply RefNode's own explicitly defined properties
|
||||
for (const [key, val] of Object.entries(node)) {
|
||||
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
|
||||
if (val !== undefined) {
|
||||
merged[key] = val
|
||||
}
|
||||
}
|
||||
// Use component's type (frame/rect/etc.) not 'ref'
|
||||
merged.type = component.type
|
||||
if (!merged.name) merged.name = component.name
|
||||
displayNode = merged as unknown as PenNode
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = (updates: Partial<PenNode>) => {
|
||||
if (!activeId) return
|
||||
if (nodeIsInstance && node.type === 'ref') {
|
||||
const refNode = node as RefNode
|
||||
const refNodeUpdate: Record<string, unknown> = {}
|
||||
const overrideProps: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (INSTANCE_DIRECT_PROPS.has(key)) {
|
||||
refNodeUpdate[key] = value
|
||||
} else {
|
||||
overrideProps[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Store visual properties as overrides in descendants[ref]
|
||||
if (Object.keys(overrideProps).length > 0) {
|
||||
const currentDescendants = refNode.descendants ?? {}
|
||||
const existing = currentDescendants[refNode.ref] ?? {}
|
||||
refNodeUpdate.descendants = {
|
||||
...currentDescendants,
|
||||
[refNode.ref]: { ...existing, ...overrideProps },
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(refNodeUpdate).length > 0) {
|
||||
updateNode(activeId, refNodeUpdate as Partial<PenNode>)
|
||||
}
|
||||
} else {
|
||||
updateNode(activeId, updates)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoToComponent = () => {
|
||||
if (!nodeIsInstance || node.type !== 'ref') return
|
||||
const refId = (node as RefNode).ref
|
||||
setSelection([refId], refId)
|
||||
if (fabricCanvas) {
|
||||
const objects = fabricCanvas.getObjects() as FabricObjectWithPenId[]
|
||||
const target = objects.find((o) => (o as FabricObjectWithPenId).penNodeId === refId)
|
||||
if (target) {
|
||||
fabricCanvas.setActiveObject(target)
|
||||
fabricCanvas.requestRenderAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isContainer =
|
||||
displayNode.type === 'frame' || displayNode.type === 'group' || displayNode.type === 'rectangle'
|
||||
const hasLayout = isContainer
|
||||
const hasFill = displayNode.type !== 'line'
|
||||
const hasStroke = true
|
||||
const hasCornerRadius =
|
||||
node.type === 'rectangle' || node.type === 'frame'
|
||||
const hasEffects = node.type !== 'ref'
|
||||
const isText = node.type === 'text'
|
||||
displayNode.type === 'rectangle' || displayNode.type === 'frame'
|
||||
const hasEffects = true
|
||||
const isText = displayNode.type === 'text'
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
// Reset editing state when selection changes
|
||||
useEffect(() => {
|
||||
setIsEditingName(false)
|
||||
}, [activeId])
|
||||
|
||||
const handleNameClick = () => {
|
||||
setEditName(node.name ?? node.type)
|
||||
setIsEditingName(true)
|
||||
}
|
||||
|
||||
const handleNameBlur = () => {
|
||||
setIsEditingName(false)
|
||||
const trimmed = editName.trim()
|
||||
if (trimmed && trimmed !== (node.name ?? node.type)) {
|
||||
updateNode(activeId!, { name: trimmed } as Partial<PenNode>)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditingName(false)
|
||||
setEditName(node.name ?? node.type)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-card border-l border-border flex flex-col shrink-0">
|
||||
<div className="h-8 flex items-center px-3 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-foreground">
|
||||
{node.name ?? node.type}
|
||||
</span>
|
||||
{/* Header */}
|
||||
<div className="h-8 flex items-center px-2 border-b border-border gap-1">
|
||||
{(nodeIsReusable || nodeIsInstance) && (
|
||||
<Diamond size={12} className={`shrink-0 ${nodeIsReusable ? 'text-purple-400' : 'text-[#9281f7]'}`} />
|
||||
)}
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleNameBlur}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
className={`text-[11px] font-medium flex-1 min-w-0 bg-secondary rounded px-1.5 py-0.5 border border-ring focus:outline-none ${
|
||||
nodeIsReusable
|
||||
? 'text-purple-400'
|
||||
: nodeIsInstance
|
||||
? 'text-[#9281f7]'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`text-[11px] font-medium flex-1 truncate cursor-text ${
|
||||
nodeIsReusable
|
||||
? 'text-purple-400 border border-purple-400/50 rounded px-1.5 py-0.5'
|
||||
: nodeIsInstance
|
||||
? 'text-[#9281f7] border border-dashed border-[#9281f7]/50 rounded px-1.5 py-0.5'
|
||||
: 'text-foreground px-1'
|
||||
}`}
|
||||
onClick={handleNameClick}
|
||||
>
|
||||
{node.name ?? node.type}
|
||||
</span>
|
||||
)}
|
||||
{nodeIsInstance && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
title="Go to component"
|
||||
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={handleGoToComponent}
|
||||
>
|
||||
<ArrowUpRight size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Detach instance"
|
||||
className="p-1 rounded hover:bg-accent/50 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={() => activeId && detachComponent(activeId)}
|
||||
>
|
||||
<Unlink size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{(isContainer || nodeIsInstance) && (
|
||||
<div className="px-3 py-2">
|
||||
{nodeIsReusable ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs gap-1.5 text-purple-400 border-purple-500/30 hover:bg-purple-500/10"
|
||||
onClick={() => activeId && detachComponent(activeId)}
|
||||
>
|
||||
<Unlink size={12} />
|
||||
Detach Component
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs gap-1.5"
|
||||
onClick={() => {
|
||||
if (!activeId) return
|
||||
if (nodeIsInstance) {
|
||||
const newId = detachComponent(activeId)
|
||||
if (newId) {
|
||||
makeReusable(newId)
|
||||
setSelection([newId], newId)
|
||||
}
|
||||
return
|
||||
}
|
||||
makeReusable(activeId)
|
||||
}}
|
||||
>
|
||||
<Component size={12} />
|
||||
Create Component
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<SizeSection
|
||||
node={node}
|
||||
node={displayNode}
|
||||
onUpdate={handleUpdate}
|
||||
hasCornerRadius={hasCornerRadius}
|
||||
cornerRadius={
|
||||
'cornerRadius' in node ? node.cornerRadius : undefined
|
||||
'cornerRadius' in displayNode ? displayNode.cornerRadius : undefined
|
||||
}
|
||||
hideWH={hasLayout || isText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -63,18 +264,33 @@ export default function PropertyPanel() {
|
|||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<LayoutSection node={node as PenNode & ContainerProps} onUpdate={handleUpdate} />
|
||||
<LayoutSection node={displayNode as PenNode & ContainerProps} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isText && displayNode.type === 'text' && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<TextLayoutSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<AppearanceSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{hasFill && (
|
||||
<>
|
||||
<div className="px-3 py-2">
|
||||
<FillSection
|
||||
fills={'fill' in node ? node.fill : undefined}
|
||||
fills={'fill' in displayNode ? displayNode.fill : undefined}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -86,7 +302,7 @@ export default function PropertyPanel() {
|
|||
<>
|
||||
<div className="px-3 py-2">
|
||||
<StrokeSection
|
||||
stroke={'stroke' in node ? node.stroke : undefined}
|
||||
stroke={'stroke' in displayNode ? displayNode.stroke : undefined}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -94,30 +310,28 @@ export default function PropertyPanel() {
|
|||
</>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<AppearanceSection node={node} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
{isText && displayNode.type === 'text' && (
|
||||
<div className="px-3 py-2">
|
||||
<TextSection node={displayNode} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasEffects && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<EffectsSection
|
||||
effects={'effects' in node ? node.effects : undefined}
|
||||
effects={'effects' in displayNode ? displayNode.effects : undefined}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isText && node.type === 'text' && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<TextSection node={node} onUpdate={handleUpdate} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Separator />
|
||||
<div className="px-3 py-2">
|
||||
<ExportSection nodeId={node.id} nodeName={node.name ?? node.type} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface SizeSectionProps {
|
|||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
hasCornerRadius?: boolean
|
||||
cornerRadius?: number | [number, number, number, number]
|
||||
hideWH?: boolean
|
||||
}
|
||||
|
||||
export default function SizeSection({
|
||||
|
|
@ -15,6 +16,7 @@ export default function SizeSection({
|
|||
onUpdate,
|
||||
hasCornerRadius,
|
||||
cornerRadius,
|
||||
hideWH,
|
||||
}: SizeSectionProps) {
|
||||
const info = nodeRenderInfo.get(node.id)
|
||||
const offsetX = info?.parentOffsetX ?? 0
|
||||
|
|
@ -40,7 +42,12 @@ export default function SizeSection({
|
|||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<span className=" text-[11px] font-medium text-foreground ">
|
||||
Position
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
|
||||
<NumberInput
|
||||
label="X"
|
||||
value={Math.round(x)}
|
||||
|
|
@ -51,7 +58,7 @@ export default function SizeSection({
|
|||
value={Math.round(y)}
|
||||
onChange={(v) => onUpdate({ y: v - offsetY })}
|
||||
/>
|
||||
{width !== undefined && (
|
||||
{!hideWH && width !== undefined && (
|
||||
<NumberInput
|
||||
label="W"
|
||||
value={Math.round(width)}
|
||||
|
|
@ -61,7 +68,7 @@ export default function SizeSection({
|
|||
min={1}
|
||||
/>
|
||||
)}
|
||||
{height !== undefined && (
|
||||
{!hideWH && height !== undefined && (
|
||||
<NumberInput
|
||||
label="H"
|
||||
value={Math.round(height)}
|
||||
|
|
@ -88,5 +95,6 @@ export default function SizeSection({
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
235
src/components/panels/text-layout-section.tsx
Normal file
235
src/components/panels/text-layout-section.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import NumberInput from '@/components/shared/number-input'
|
||||
import SectionHeader from '@/components/shared/section-header'
|
||||
import {
|
||||
MoveHorizontal,
|
||||
WrapText,
|
||||
Maximize2,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PenNode, TextNode, SizingBehavior } from '@/types/pen'
|
||||
|
||||
interface TextLayoutSectionProps {
|
||||
node: TextNode
|
||||
onUpdate: (updates: Partial<PenNode>) => void
|
||||
}
|
||||
|
||||
type TextResizing = 'auto' | 'fixed-width' | 'fixed-width-height'
|
||||
|
||||
function resolveTextGrowth(node: TextNode): TextResizing {
|
||||
if (node.textGrowth) return node.textGrowth
|
||||
const w = node.width
|
||||
if (typeof w === 'number' && w > 0) return 'fixed-width'
|
||||
if (typeof w === 'string' && w.startsWith('fill_container')) return 'fixed-width'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function extractNumericSize(value: SizingBehavior | undefined): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const match = value.match(/\((\d+)\)/)
|
||||
if (match) return parseInt(match[1], 10)
|
||||
}
|
||||
return 100
|
||||
}
|
||||
|
||||
function ResizingToggle({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-7 flex-1 flex items-center justify-center gap-1 rounded text-[10px] transition-colors',
|
||||
active
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground hover:text-foreground hover:bg-accent',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SizingCheckbox({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-1.5 cursor-pointer group">
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'w-4 h-4 rounded-[3px] border-[1.5px] flex items-center justify-center transition-colors shrink-0',
|
||||
checked
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-muted-foreground/40 group-hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground select-none">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TextLayoutSection({
|
||||
node,
|
||||
onUpdate,
|
||||
}: TextLayoutSectionProps) {
|
||||
const resizing = resolveTextGrowth(node)
|
||||
const widthStr = typeof node.width === 'string' ? node.width : ''
|
||||
const heightStr = typeof node.height === 'string' ? node.height : ''
|
||||
const fillWidth = widthStr.startsWith('fill_container')
|
||||
const fillHeight = heightStr.startsWith('fill_container')
|
||||
const numericWidth = typeof node.width === 'number' && node.width > 0 ? node.width : undefined
|
||||
const numericHeight = typeof node.height === 'number' && node.height > 0 ? node.height : undefined
|
||||
const fallbackW = extractNumericSize(node.width)
|
||||
const fallbackH = extractNumericSize(node.height)
|
||||
|
||||
const handleResizingChange = (mode: TextResizing) => {
|
||||
const updates: Record<string, unknown> = { textGrowth: mode }
|
||||
switch (mode) {
|
||||
case 'auto':
|
||||
updates.width = 0
|
||||
updates.height = 0
|
||||
break
|
||||
case 'fixed-width':
|
||||
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
|
||||
updates.width = fallbackW > 0 ? fallbackW : 200
|
||||
}
|
||||
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
|
||||
updates.height = fallbackH > 0 ? fallbackH : 44
|
||||
}
|
||||
break
|
||||
case 'fixed-width-height':
|
||||
if (!fillWidth && (typeof node.width !== 'number' || node.width <= 0)) {
|
||||
updates.width = fallbackW > 0 ? fallbackW : 200
|
||||
}
|
||||
if (!fillHeight && (typeof node.height !== 'number' || node.height <= 0)) {
|
||||
updates.height = fallbackH > 0 ? fallbackH : 100
|
||||
}
|
||||
break
|
||||
}
|
||||
onUpdate(updates as Partial<PenNode>)
|
||||
}
|
||||
|
||||
// Always show dimensions — read-only when not directly editable
|
||||
const canEditWidth = resizing !== 'auto' && !fillWidth && numericWidth !== undefined
|
||||
const canEditHeight = resizing === 'fixed-width-height' && !fillHeight && numericHeight !== undefined
|
||||
// Display value: prefer numeric, fallback to extracted size from fill_container(N)
|
||||
const displayW = numericWidth ?? fallbackW
|
||||
const displayH = numericHeight ?? fallbackH
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Layout" />
|
||||
|
||||
{/* Dimensions — always visible, read-only when auto/fill */}
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1 block">
|
||||
Dimensions
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
label="W"
|
||||
value={Math.round(displayW)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ width: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
readOnly={!canEditWidth}
|
||||
/>
|
||||
<NumberInput
|
||||
label="H"
|
||||
value={Math.round(displayH)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ height: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
readOnly={!canEditHeight}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fill Width / Fill Height */}
|
||||
{resizing !== 'auto' && (
|
||||
<div className="grid grid-cols-2 gap-y-1.5">
|
||||
<SizingCheckbox
|
||||
label="Fill Width"
|
||||
checked={fillWidth}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
width: v ? 'fill_container' : (fallbackW > 0 ? fallbackW : 200),
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
<SizingCheckbox
|
||||
label="Fill Height"
|
||||
checked={fillHeight}
|
||||
onChange={(v) =>
|
||||
onUpdate({
|
||||
height: v ? 'fill_container' : (fallbackH > 0 ? fallbackH : 100),
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resizing mode */}
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground mb-1 block">
|
||||
Resizing
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
<ResizingToggle
|
||||
active={resizing === 'auto'}
|
||||
onClick={() => handleResizingChange('auto')}
|
||||
title="Auto Width — text expands horizontally"
|
||||
>
|
||||
<MoveHorizontal className="w-3 h-3" />
|
||||
<span>Auto W</span>
|
||||
</ResizingToggle>
|
||||
<ResizingToggle
|
||||
active={resizing === 'fixed-width'}
|
||||
onClick={() => handleResizingChange('fixed-width')}
|
||||
title="Auto Height — fixed width, height auto-sizes"
|
||||
>
|
||||
<WrapText className="w-3 h-3" />
|
||||
<span>Auto H</span>
|
||||
</ResizingToggle>
|
||||
<ResizingToggle
|
||||
active={resizing === 'fixed-width-height'}
|
||||
onClick={() => handleResizingChange('fixed-width-height')}
|
||||
title="Fixed Size — both width and height are fixed"
|
||||
>
|
||||
<Maximize2 className="w-3 h-3" />
|
||||
<span>Fixed</span>
|
||||
</ResizingToggle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,15 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { AlignLeft, AlignCenter, AlignRight, AlignJustify } from 'lucide-react'
|
||||
import {
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
AlignVerticalJustifyStart,
|
||||
AlignVerticalJustifyCenter,
|
||||
AlignVerticalJustifyEnd,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PenNode, TextNode } from '@/types/pen'
|
||||
|
||||
|
|
@ -35,13 +43,58 @@ const WEIGHT_OPTIONS = [
|
|||
{ value: '900', label: 'Black' },
|
||||
]
|
||||
|
||||
const ALIGN_OPTIONS = [
|
||||
const H_ALIGN_OPTIONS = [
|
||||
{ value: 'left', icon: AlignLeft, label: 'Align left' },
|
||||
{ value: 'center', icon: AlignCenter, label: 'Align center' },
|
||||
{ value: 'right', icon: AlignRight, label: 'Align right' },
|
||||
{ value: 'justify', icon: AlignJustify, label: 'Justify' },
|
||||
]
|
||||
|
||||
const V_ALIGN_OPTIONS = [
|
||||
{ value: 'top', icon: AlignVerticalJustifyStart, label: 'Top' },
|
||||
{ value: 'middle', icon: AlignVerticalJustifyCenter, label: 'Middle' },
|
||||
{ value: 'bottom', icon: AlignVerticalJustifyEnd, label: 'Bottom' },
|
||||
]
|
||||
|
||||
const LineHeightIcon = (
|
||||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
|
||||
<line x1="1" y1="2" x2="1" y2="10" />
|
||||
<polyline points="3,4 1,2 -1,4" transform="translate(0,0)" />
|
||||
<polyline points="3,8 1,10 -1,8" transform="translate(0,0)" />
|
||||
<line x1="5" y1="6" x2="11" y2="6" />
|
||||
<line x1="5" y1="3" x2="9" y2="3" />
|
||||
<line x1="5" y1="9" x2="9" y2="9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
function AlignButton({
|
||||
active,
|
||||
onClick,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'h-6 w-6 flex items-center justify-center rounded transition-colors',
|
||||
active
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TextSection({
|
||||
node,
|
||||
onUpdate,
|
||||
|
|
@ -49,12 +102,16 @@ export default function TextSection({
|
|||
const fontFamily = node.fontFamily ?? 'Inter, sans-serif'
|
||||
const fontSize = node.fontSize ?? 16
|
||||
const fontWeight = String(node.fontWeight ?? '400')
|
||||
const lineHeight = node.lineHeight ?? 1.2
|
||||
const letterSpacing = node.letterSpacing ?? 0
|
||||
const textAlign = node.textAlign ?? 'left'
|
||||
const textAlignVertical = node.textAlignVertical ?? 'top'
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<SectionHeader title="Text" />
|
||||
<SectionHeader title="Typography" />
|
||||
|
||||
{/* Font family */}
|
||||
<Select
|
||||
value={fontFamily}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -73,15 +130,8 @@ export default function TextSection({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Weight + Size */}
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
value={fontSize}
|
||||
onChange={(v) =>
|
||||
onUpdate({ fontSize: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
max={999}
|
||||
/>
|
||||
<Select
|
||||
value={fontWeight}
|
||||
onValueChange={(v) =>
|
||||
|
|
@ -99,29 +149,80 @@ export default function TextSection({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberInput
|
||||
label="S"
|
||||
value={fontSize}
|
||||
onChange={(v) =>
|
||||
onUpdate({ fontSize: v } as Partial<PenNode>)
|
||||
}
|
||||
min={1}
|
||||
max={999}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
{ALIGN_OPTIONS.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlign: value as TextNode['textAlign'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
className={cn(
|
||||
'h-6 w-6 flex items-center justify-center rounded transition-colors',
|
||||
textAlign === value
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-secondary/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
{/* Line height + Letter spacing */}
|
||||
<div className="flex items-center justify-between text-[9px] text-muted-foreground px-0.5">
|
||||
<span>Line height</span>
|
||||
<span>Letter spacing</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<NumberInput
|
||||
icon={LineHeightIcon}
|
||||
value={Math.round(lineHeight * 100)}
|
||||
onChange={(v) =>
|
||||
onUpdate({ lineHeight: v / 100 } as Partial<PenNode>)
|
||||
}
|
||||
min={50}
|
||||
max={400}
|
||||
suffix="%"
|
||||
/>
|
||||
<NumberInput
|
||||
label="|A|"
|
||||
value={letterSpacing}
|
||||
onChange={(v) =>
|
||||
onUpdate({ letterSpacing: v } as Partial<PenNode>)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Horizontal alignment */}
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Horizontal</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{H_ALIGN_OPTIONS.map(({ value, icon, label }) => (
|
||||
<AlignButton
|
||||
key={value}
|
||||
active={textAlign === value}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlign: value as TextNode['textAlign'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vertical alignment */}
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] text-muted-foreground">Vertical</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{V_ALIGN_OPTIONS.map(({ value, icon, label }) => (
|
||||
<AlignButton
|
||||
key={value}
|
||||
active={textAlignVertical === value}
|
||||
onClick={() =>
|
||||
onUpdate({
|
||||
textAlignVertical: value as TextNode['textAlignVertical'],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
icon={icon}
|
||||
label={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import { X, Check, Loader2, Unplug } from 'lucide-react'
|
||||
import { X, Check, Loader2, Unplug, AlertCircle } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
|
@ -8,11 +8,12 @@ import { useAgentSettingsStore } from '@/stores/agent-settings-store'
|
|||
import type { AIProviderType, GroupedModel } from '@/types/agent-settings'
|
||||
import ClaudeLogo from '@/components/icons/claude-logo'
|
||||
import OpenAILogo from '@/components/icons/openai-logo'
|
||||
import OpenCodeLogo from '@/components/icons/opencode-logo'
|
||||
|
||||
/** Provider display metadata */
|
||||
const PROVIDER_META: Record<
|
||||
AIProviderType,
|
||||
{ label: string; description: string; agent: 'claude-code' | 'codex-cli'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
|
||||
{ label: string; description: string; agent: 'claude-code' | 'codex-cli' | 'opencode'; Icon: ComponentType<SVGProps<SVGSVGElement>> }
|
||||
> = {
|
||||
anthropic: {
|
||||
label: 'Claude Code',
|
||||
|
|
@ -26,10 +27,16 @@ const PROVIDER_META: Record<
|
|||
agent: 'codex-cli',
|
||||
Icon: OpenAILogo,
|
||||
},
|
||||
opencode: {
|
||||
label: 'OpenCode',
|
||||
description: 'Connect to local OpenCode server to use 75+ LLM providers',
|
||||
agent: 'opencode',
|
||||
Icon: OpenCodeLogo,
|
||||
},
|
||||
}
|
||||
|
||||
async function connectAgent(
|
||||
agent: 'claude-code' | 'codex-cli',
|
||||
agent: 'claude-code' | 'codex-cli' | 'opencode',
|
||||
): Promise<{ connected: boolean; models: GroupedModel[]; error?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/ai/connect-agent', {
|
||||
|
|
@ -147,12 +154,37 @@ export default function AgentSettingsDialog() {
|
|||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [open, setDialogOpen])
|
||||
|
||||
const [mcpInstalling, setMcpInstalling] = useState<string | null>(null)
|
||||
const [mcpError, setMcpError] = useState<string | null>(null)
|
||||
|
||||
const handleToggleMCP = useCallback(
|
||||
(tool: string) => {
|
||||
toggleMCP(tool)
|
||||
persist()
|
||||
async (tool: string) => {
|
||||
const current = mcpIntegrations.find((m) => m.tool === tool)
|
||||
if (!current) return
|
||||
const action = current.enabled ? 'uninstall' : 'install'
|
||||
|
||||
setMcpInstalling(tool)
|
||||
setMcpError(null)
|
||||
try {
|
||||
const res = await fetch('/api/ai/mcp-install', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tool, action }),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (result.success) {
|
||||
toggleMCP(tool)
|
||||
persist()
|
||||
} else {
|
||||
setMcpError(result.error ?? `Failed to ${action}`)
|
||||
}
|
||||
} catch {
|
||||
setMcpError(`Failed to ${action} MCP server`)
|
||||
} finally {
|
||||
setMcpInstalling(null)
|
||||
}
|
||||
},
|
||||
[toggleMCP, persist],
|
||||
[mcpIntegrations, toggleMCP, persist],
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
|
@ -194,6 +226,7 @@ export default function AgentSettingsDialog() {
|
|||
<div className="space-y-2">
|
||||
<ProviderCard type="anthropic" />
|
||||
<ProviderCard type="openai" />
|
||||
<ProviderCard type="opencode" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -208,21 +241,33 @@ export default function AgentSettingsDialog() {
|
|||
key={m.tool}
|
||||
className="flex items-center justify-between py-2 px-1"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
m.enabled ? 'text-foreground' : 'text-muted-foreground',
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs',
|
||||
m.enabled ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{m.displayName}
|
||||
</span>
|
||||
{mcpInstalling === m.tool && (
|
||||
<Loader2 size={11} className="animate-spin text-muted-foreground" />
|
||||
)}
|
||||
>
|
||||
{m.displayName}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={m.enabled}
|
||||
disabled={mcpInstalling !== null}
|
||||
onCheckedChange={() => handleToggleMCP(m.tool)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{mcpError && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 px-1">
|
||||
<AlertCircle size={11} className="text-destructive shrink-0" />
|
||||
<p className="text-[10px] text-destructive">{mcpError}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-2">
|
||||
MCP integrations will take effect after restarting the terminal.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default function ColorPicker({
|
|||
type="color"
|
||||
value={value.slice(0, 7)}
|
||||
onChange={handleNativeChange}
|
||||
className="w-4 h-4 rounded border border-input/50 cursor-pointer bg-transparent p-0"
|
||||
className="w-4 h-5 rounded cursor-pointer bg-transparent p-0"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
|
|
@ -64,7 +64,7 @@ export default function ColorPicker({
|
|||
value={hexInput}
|
||||
onChange={handleHexChange}
|
||||
onBlur={handleBlur}
|
||||
className="flex-1 bg-transparent text-foreground text-[11px] px-1.5 py-0.5 focus:outline-none font-mono tabular-nums min-w-0"
|
||||
className="flex-1 bg-transparent text-foreground text-[11px] px-1.5 h-5 focus:outline-none font-mono tabular-nums min-w-0"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ interface NumberInputProps {
|
|||
icon?: React.ReactNode
|
||||
suffix?: string
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export default function NumberInput({
|
||||
|
|
@ -25,6 +26,7 @@ export default function NumberInput({
|
|||
icon,
|
||||
suffix,
|
||||
className = '',
|
||||
readOnly = false,
|
||||
}: NumberInputProps) {
|
||||
const [localValue, setLocalValue] = useState(String(value))
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
|
@ -69,6 +71,7 @@ export default function NumberInput({
|
|||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (readOnly) return
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
setIsDragging(true)
|
||||
dragStartY.current = e.clientY
|
||||
|
|
@ -116,10 +119,14 @@ export default function NumberInput({
|
|||
<input
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent text-foreground text-[11px] px-1 py-0.5 focus:outline-none tabular-nums"
|
||||
onChange={(e) => !readOnly && setLocalValue(e.target.value)}
|
||||
onBlur={readOnly ? undefined : handleBlur}
|
||||
onKeyDown={readOnly ? undefined : handleKeyDown}
|
||||
readOnly={readOnly}
|
||||
className={cn(
|
||||
'w-full bg-transparent text-[11px] px-1 py-0.5 focus:outline-none tabular-nums',
|
||||
readOnly ? 'text-muted-foreground cursor-default' : 'text-foreground',
|
||||
)}
|
||||
/>
|
||||
{suffix && (
|
||||
<span className="text-[10px] text-muted-foreground pr-1.5 shrink-0">
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
|
|||
// Pre-fill with existing name (without extension)
|
||||
const fn = useDocumentStore.getState().fileName
|
||||
if (fn) {
|
||||
setName(fn.replace(/\.pen$|\.json$/, ''))
|
||||
setName(fn.replace(/\.op$|\.pen$|\.json$/, ''))
|
||||
} else {
|
||||
setName('untitled')
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
|
|||
if (!trimmed) return
|
||||
// Force-sync all Fabric object positions to the store before serializing
|
||||
syncCanvasPositionsToStore()
|
||||
const fileName = trimmed.endsWith('.pen') ? trimmed : `${trimmed}.pen`
|
||||
const fileName = trimmed.endsWith('.op') ? trimmed : `${trimmed}.op`
|
||||
const doc = useDocumentStore.getState().document
|
||||
downloadDocument(doc, fileName)
|
||||
useDocumentStore.setState({ fileName, isDirty: false })
|
||||
|
|
@ -74,7 +74,7 @@ export default function SaveDialog({ open, onClose }: SaveDialogProps) {
|
|||
className="flex-1 bg-secondary border border-input rounded px-2 py-1.5 text-sm text-foreground focus:outline-none focus:border-ring"
|
||||
autoFocus
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">.pen</span>
|
||||
<span className="text-xs text-muted-foreground">.op</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -128,11 +128,23 @@ export function useKeyboardShortcuts() {
|
|||
const { clipboard } = useCanvasStore.getState()
|
||||
if (clipboard.length > 0) {
|
||||
e.preventDefault()
|
||||
const cloned = cloneNodesWithNewIds(clipboard, 10)
|
||||
const newIds: string[] = []
|
||||
for (const node of cloned) {
|
||||
useDocumentStore.getState().addNode(null, node)
|
||||
newIds.push(node.id)
|
||||
for (const original of clipboard) {
|
||||
// Pasting a reusable component creates an instance (RefNode)
|
||||
if ('reusable' in original && original.reusable) {
|
||||
const component = useDocumentStore.getState().getNodeById(original.id)
|
||||
if (component && 'reusable' in component && component.reusable) {
|
||||
const newId = useDocumentStore.getState().duplicateNode(original.id)
|
||||
if (newId) {
|
||||
newIds.push(newId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
// Regular paste for non-reusable nodes
|
||||
const [cloned] = cloneNodesWithNewIds([original], 10)
|
||||
useDocumentStore.getState().addNode(null, cloned)
|
||||
newIds.push(cloned.id)
|
||||
}
|
||||
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
|
||||
}
|
||||
|
|
@ -144,16 +156,14 @@ export function useKeyboardShortcuts() {
|
|||
const { selectedIds } = useCanvasStore.getState().selection
|
||||
if (selectedIds.length > 0) {
|
||||
e.preventDefault()
|
||||
const nodes = selectedIds
|
||||
.map((id) => useDocumentStore.getState().getNodeById(id))
|
||||
.filter((n): n is NonNullable<typeof n> => n != null)
|
||||
const cloned = cloneNodesWithNewIds(nodes, 10)
|
||||
const newIds: string[] = []
|
||||
for (const node of cloned) {
|
||||
useDocumentStore.getState().addNode(null, node)
|
||||
newIds.push(node.id)
|
||||
for (const id of selectedIds) {
|
||||
const newId = useDocumentStore.getState().duplicateNode(id)
|
||||
if (newId) newIds.push(newId)
|
||||
}
|
||||
if (newIds.length > 0) {
|
||||
useCanvasStore.getState().setSelection(newIds, newIds[0])
|
||||
}
|
||||
useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -168,8 +178,11 @@ export function useKeyboardShortcuts() {
|
|||
|
||||
if (fileHandle) {
|
||||
writeToFileHandle(fileHandle, doc).then(() => store.markClean())
|
||||
} else if (fileName) {
|
||||
downloadDocument(doc, fileName)
|
||||
store.markClean()
|
||||
} else if (supportsFileSystemAccess()) {
|
||||
saveDocumentAs(doc, fileName ?? 'untitled.pen').then((result) => {
|
||||
saveDocumentAs(doc, 'untitled.op').then((result) => {
|
||||
if (result) {
|
||||
useDocumentStore.setState({
|
||||
fileName: result.fileName,
|
||||
|
|
@ -178,9 +191,6 @@ export function useKeyboardShortcuts() {
|
|||
})
|
||||
}
|
||||
})
|
||||
} else if (fileName) {
|
||||
downloadDocument(doc, fileName)
|
||||
store.markClean()
|
||||
} else {
|
||||
store.setSaveDialogOpen(true)
|
||||
}
|
||||
|
|
@ -225,6 +235,16 @@ export function useKeyboardShortcuts() {
|
|||
return
|
||||
}
|
||||
|
||||
// Create Component: Cmd/Ctrl+Alt+K
|
||||
if (isMod && e.altKey && e.key.toLowerCase() === 'k') {
|
||||
const { selectedIds } = useCanvasStore.getState().selection
|
||||
if (selectedIds.length === 1) {
|
||||
e.preventDefault()
|
||||
useDocumentStore.getState().makeReusable(selectedIds[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Ungroup: Cmd/Ctrl+Shift+G
|
||||
if (isMod && e.shiftKey && e.key.toLowerCase() === 'g') {
|
||||
const { selectedIds } = useCanvasStore.getState().selection
|
||||
|
|
|
|||
75
src/mcp/document-manager.ts
Normal file
75
src/mcp/document-manager.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { readFile, writeFile, access } from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import type { PenDocument } from '../types/pen'
|
||||
|
||||
const cache = new Map<string, { doc: PenDocument; mtime: number }>()
|
||||
|
||||
/** Validate that a parsed object looks like a PenDocument. */
|
||||
function validate(doc: unknown): doc is PenDocument {
|
||||
if (!doc || typeof doc !== 'object') return false
|
||||
const d = doc as Record<string, unknown>
|
||||
return typeof d.version === 'string' && Array.isArray(d.children)
|
||||
}
|
||||
|
||||
/** Read and parse a .op / .pen file, returning a PenDocument. Uses cache. */
|
||||
export async function openDocument(filePath: string): Promise<PenDocument> {
|
||||
const cached = cache.get(filePath)
|
||||
if (cached) return cached.doc
|
||||
|
||||
await access(filePath, constants.R_OK)
|
||||
const text = await readFile(filePath, 'utf-8')
|
||||
const raw = JSON.parse(text)
|
||||
if (!validate(raw)) {
|
||||
throw new Error(`Invalid document format: ${filePath}`)
|
||||
}
|
||||
cache.set(filePath, { doc: raw, mtime: Date.now() })
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Create a new empty document (not saved to disk yet). */
|
||||
export function createEmptyDocument(): PenDocument {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a PenDocument to disk and update cache. */
|
||||
export async function saveDocument(
|
||||
filePath: string,
|
||||
doc: PenDocument,
|
||||
): Promise<void> {
|
||||
const json = JSON.stringify(doc, null, 2)
|
||||
await writeFile(filePath, json, 'utf-8')
|
||||
cache.set(filePath, { doc, mtime: Date.now() })
|
||||
}
|
||||
|
||||
/** Get document from cache (for tools that operate on the active doc). */
|
||||
export function getCachedDocument(
|
||||
filePath: string,
|
||||
): PenDocument | undefined {
|
||||
return cache.get(filePath)?.doc
|
||||
}
|
||||
|
||||
/** Update the cached document in-memory (call saveDocument to persist). */
|
||||
export function setCachedDocument(
|
||||
filePath: string,
|
||||
doc: PenDocument,
|
||||
): void {
|
||||
cache.set(filePath, { doc, mtime: Date.now() })
|
||||
}
|
||||
|
||||
/** Check if a file exists. */
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath, constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidate cache for a file. */
|
||||
export function invalidateCache(filePath: string): void {
|
||||
cache.delete(filePath)
|
||||
}
|
||||
274
src/mcp/server.ts
Normal file
274
src/mcp/server.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
import { handleOpenDocument } from './tools/open-document'
|
||||
import { handleBatchGet } from './tools/batch-get'
|
||||
import { handleBatchDesign } from './tools/batch-design'
|
||||
import { handleGetVariables, handleSetVariables } from './tools/variables'
|
||||
import { handleSnapshotLayout } from './tools/snapshot-layout'
|
||||
import { handleFindEmptySpace } from './tools/find-empty-space'
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'openpencil', version: '0.1.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
// --- Tool definitions ---
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: 'open_document',
|
||||
description:
|
||||
'Open an existing .op file or create a new empty document. Returns document metadata.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file to open or create',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'batch_get',
|
||||
description:
|
||||
'Search and read nodes from an .op file. Search by patterns (type, name regex, reusable flag) or read specific node IDs. Control depth with readDepth and searchDepth.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
patterns: {
|
||||
type: 'array',
|
||||
description: 'Search patterns to match nodes',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Node type (frame, text, rectangle, etc.)' },
|
||||
name: { type: 'string', description: 'Regex pattern to match node name' },
|
||||
reusable: { type: 'boolean', description: 'Match reusable components' },
|
||||
},
|
||||
},
|
||||
},
|
||||
nodeIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Specific node IDs to read',
|
||||
},
|
||||
parentId: {
|
||||
type: 'string',
|
||||
description: 'Limit search to children of this parent node',
|
||||
},
|
||||
readDepth: {
|
||||
type: 'number',
|
||||
description: 'How deep to include children in results (default 1)',
|
||||
},
|
||||
searchDepth: {
|
||||
type: 'number',
|
||||
description: 'How deep to search for matching nodes (default unlimited)',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'batch_design',
|
||||
description: `Execute design operations on an .op file using a DSL. Each line is one operation:
|
||||
- Insert: binding=I(parent, { type: "frame", ... })
|
||||
- Copy: binding=C(sourceId, parent, { ...overrides })
|
||||
- Update: U(nodeId, { fill: [...] })
|
||||
- Replace: binding=R(nodeId, { type: "text", ... })
|
||||
- Move: M(nodeId, newParent, index)
|
||||
- Delete: D(nodeId)
|
||||
|
||||
Bindings can reference earlier results: U(myFrame+"/childId", { ... })`,
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
operations: {
|
||||
type: 'string',
|
||||
description: 'Operations DSL (one operation per line)',
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'operations'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_variables',
|
||||
description: 'Get all design variables and themes defined in an .op file.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'set_variables',
|
||||
description:
|
||||
'Add or update design variables in an .op file. By default merges with existing variables.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
variables: {
|
||||
type: 'object',
|
||||
description: 'Variables to set (name → { type, value })',
|
||||
},
|
||||
replace: {
|
||||
type: 'boolean',
|
||||
description: 'Replace all variables instead of merging (default false)',
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'variables'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'snapshot_layout',
|
||||
description:
|
||||
'Get the hierarchical bounding box layout tree of an .op file. Useful for understanding spatial arrangement.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
parentId: {
|
||||
type: 'string',
|
||||
description: 'Only return layout under this parent node',
|
||||
},
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Max depth to traverse (default 1)',
|
||||
},
|
||||
},
|
||||
required: ['filePath'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'find_empty_space',
|
||||
description:
|
||||
'Find empty canvas space in a given direction for placing new content.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
filePath: {
|
||||
type: 'string',
|
||||
description: 'Absolute path to the .op file',
|
||||
},
|
||||
width: {
|
||||
type: 'number',
|
||||
description: 'Required width of empty space',
|
||||
},
|
||||
height: {
|
||||
type: 'number',
|
||||
description: 'Required height of empty space',
|
||||
},
|
||||
padding: {
|
||||
type: 'number',
|
||||
description: 'Minimum padding from other elements (default 50)',
|
||||
},
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['top', 'right', 'bottom', 'left'],
|
||||
description: 'Direction to search for empty space',
|
||||
},
|
||||
nodeId: {
|
||||
type: 'string',
|
||||
description: 'Search relative to this node (default: entire canvas)',
|
||||
},
|
||||
},
|
||||
required: ['filePath', 'width', 'height', 'direction'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// --- Tool execution ---
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case 'open_document': {
|
||||
const result = await handleOpenDocument(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'batch_get': {
|
||||
const result = await handleBatchGet(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'batch_design': {
|
||||
const result = await handleBatchDesign(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'get_variables': {
|
||||
const result = await handleGetVariables(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'set_variables': {
|
||||
const result = await handleSetVariables(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'snapshot_layout': {
|
||||
const result = await handleSnapshotLayout(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
case 'find_empty_space': {
|
||||
const result = await handleFindEmptySpace(args as any)
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }
|
||||
}
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// --- Start ---
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('MCP server failed to start:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
441
src/mcp/tools/batch-design.ts
Normal file
441
src/mcp/tools/batch-design.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { openDocument, saveDocument } from '../document-manager'
|
||||
import {
|
||||
findNodeInTree,
|
||||
insertNodeInTree,
|
||||
updateNodeInTree,
|
||||
removeNodeFromTree,
|
||||
cloneNodeWithNewIds,
|
||||
} from '../utils/node-operations'
|
||||
import { generateId } from '../utils/id'
|
||||
import type { PenDocument, PenNode } from '../../types/pen'
|
||||
|
||||
export interface BatchDesignParams {
|
||||
filePath: string
|
||||
operations: string
|
||||
}
|
||||
|
||||
interface OpResult {
|
||||
binding: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute the batch_design operations DSL.
|
||||
*
|
||||
* Supported operations (one per line):
|
||||
* binding=I(parent, { ...nodeData }) — Insert
|
||||
* binding=C(nodeId, parent, { ...data }) — Copy
|
||||
* U(path, { ...updates }) — Update
|
||||
* binding=R(path, { ...nodeData }) — Replace
|
||||
* M(nodeId, parent, index?) — Move
|
||||
* D(nodeId) — Delete
|
||||
*/
|
||||
export async function handleBatchDesign(
|
||||
params: BatchDesignParams,
|
||||
): Promise<{ results: OpResult[]; nodeCount: number }> {
|
||||
const filePath = resolve(params.filePath)
|
||||
let doc = await openDocument(filePath)
|
||||
doc = structuredClone(doc)
|
||||
|
||||
const bindings = new Map<string, string>()
|
||||
const results: OpResult[] = []
|
||||
const lines = params.operations
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('//'))
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
executeLine(line, doc, bindings, results)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Error executing "${line}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await saveDocument(filePath, doc)
|
||||
|
||||
return {
|
||||
results,
|
||||
nodeCount: countNodes(doc.children),
|
||||
}
|
||||
}
|
||||
|
||||
function executeLine(
|
||||
line: string,
|
||||
doc: PenDocument,
|
||||
bindings: Map<string, string>,
|
||||
results: OpResult[],
|
||||
): void {
|
||||
// Parse: binding=OP(args) or OP(args)
|
||||
const assignMatch = line.match(/^(\w+)\s*=\s*([ICRM])\((.+)\)$/)
|
||||
const callMatch = line.match(/^([UDM])\((.+)\)$/)
|
||||
|
||||
if (assignMatch) {
|
||||
const [, binding, op, argsStr] = assignMatch
|
||||
switch (op) {
|
||||
case 'I': {
|
||||
const { parent, data } = parseInsertArgs(argsStr, bindings)
|
||||
const node = { ...data, id: generateId() } as PenNode
|
||||
doc.children = insertNodeInTree(doc.children, parent, node)
|
||||
bindings.set(binding, node.id)
|
||||
results.push({ binding, nodeId: node.id })
|
||||
break
|
||||
}
|
||||
case 'C': {
|
||||
const { sourceId, parent, data } = parseCopyArgs(argsStr, bindings)
|
||||
const source = findNodeInTree(doc.children, sourceId)
|
||||
if (!source) throw new Error(`Copy source not found: ${sourceId}`)
|
||||
const cloned = cloneNodeWithNewIds(source, generateId)
|
||||
// Apply override properties
|
||||
if (data) {
|
||||
Object.assign(cloned, data)
|
||||
// Don't override the cloned id
|
||||
if (data.id) delete (cloned as any).id
|
||||
}
|
||||
// Apply descendant overrides
|
||||
if (data?.descendants) {
|
||||
applyDescendantOverrides(cloned, data.descendants)
|
||||
}
|
||||
doc.children = insertNodeInTree(doc.children, parent, cloned)
|
||||
bindings.set(binding, cloned.id)
|
||||
results.push({ binding, nodeId: cloned.id })
|
||||
break
|
||||
}
|
||||
case 'R': {
|
||||
const { path, data } = parseReplaceArgs(argsStr, bindings)
|
||||
const resolvedPath = resolveSlashPath(path, doc, bindings)
|
||||
const newNode = { ...data, id: generateId() } as PenNode
|
||||
// Find and replace the node
|
||||
const oldNode = findNodeByPath(resolvedPath, doc)
|
||||
if (!oldNode) throw new Error(`Replace target not found: ${path}`)
|
||||
// Remove old, insert new at same position
|
||||
const parent = findParentByPath(resolvedPath, doc)
|
||||
const parentId = parent ? parent.id : null
|
||||
const siblings = parent
|
||||
? ('children' in parent ? parent.children ?? [] : [])
|
||||
: doc.children
|
||||
const idx = siblings.findIndex((n) => n.id === oldNode.id)
|
||||
doc.children = removeNodeFromTree(doc.children, oldNode.id)
|
||||
doc.children = insertNodeInTree(doc.children, parentId, newNode, idx)
|
||||
bindings.set(binding, newNode.id)
|
||||
results.push({ binding, nodeId: newNode.id })
|
||||
break
|
||||
}
|
||||
case 'M': {
|
||||
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
|
||||
const node = findNodeInTree(doc.children, nodeId)
|
||||
if (!node) throw new Error(`Move target not found: ${nodeId}`)
|
||||
doc.children = removeNodeFromTree(doc.children, nodeId)
|
||||
doc.children = insertNodeInTree(doc.children, parent, node, index)
|
||||
bindings.set(binding, nodeId)
|
||||
results.push({ binding, nodeId })
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (callMatch) {
|
||||
const [, op, argsStr] = callMatch
|
||||
switch (op) {
|
||||
case 'U': {
|
||||
const { path, data } = parseUpdateArgs(argsStr, bindings)
|
||||
const resolvedPath = resolveSlashPath(path, doc, bindings)
|
||||
const targetNode = findNodeByPath(resolvedPath, doc)
|
||||
if (!targetNode)
|
||||
throw new Error(`Update target not found: ${path}`)
|
||||
// Update the node in-place
|
||||
doc.children = updateNodeInTree(doc.children, targetNode.id, data)
|
||||
break
|
||||
}
|
||||
case 'D': {
|
||||
const nodeId = resolveRef(argsStr.trim().replace(/^"|"$/g, ''), bindings)
|
||||
doc.children = removeNodeFromTree(doc.children, nodeId)
|
||||
break
|
||||
}
|
||||
case 'M': {
|
||||
const { nodeId, parent, index } = parseMoveArgs(argsStr, bindings)
|
||||
const node = findNodeInTree(doc.children, nodeId)
|
||||
if (!node) throw new Error(`Move target not found: ${nodeId}`)
|
||||
doc.children = removeNodeFromTree(doc.children, nodeId)
|
||||
doc.children = insertNodeInTree(doc.children, parent, node, index)
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Cannot parse operation: ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Argument parsers ---
|
||||
|
||||
function parseInsertArgs(
|
||||
argsStr: string,
|
||||
bindings: Map<string, string>,
|
||||
): { parent: string | null; data: Record<string, any> } {
|
||||
const firstComma = findTopLevelComma(argsStr)
|
||||
if (firstComma === -1) throw new Error('Insert requires parent and node data')
|
||||
const parentRaw = argsStr.slice(0, firstComma).trim()
|
||||
const dataStr = argsStr.slice(firstComma + 1).trim()
|
||||
const parent = parentRaw === 'null' ? null : resolveRef(parentRaw, bindings)
|
||||
const data = parseJsonArg(dataStr)
|
||||
return { parent, data }
|
||||
}
|
||||
|
||||
function parseCopyArgs(
|
||||
argsStr: string,
|
||||
bindings: Map<string, string>,
|
||||
): { sourceId: string; parent: string | null; data: Record<string, any> } {
|
||||
const first = findTopLevelComma(argsStr)
|
||||
if (first === -1) throw new Error('Copy requires sourceId, parent, and data')
|
||||
const sourceRaw = argsStr.slice(0, first).trim()
|
||||
const rest = argsStr.slice(first + 1).trim()
|
||||
const second = findTopLevelComma(rest)
|
||||
|
||||
let parentRaw: string
|
||||
let dataStr: string
|
||||
|
||||
if (second === -1) {
|
||||
parentRaw = rest
|
||||
dataStr = '{}'
|
||||
} else {
|
||||
parentRaw = rest.slice(0, second).trim()
|
||||
dataStr = rest.slice(second + 1).trim()
|
||||
}
|
||||
|
||||
return {
|
||||
sourceId: resolveRef(sourceRaw, bindings),
|
||||
parent: parentRaw === 'null' ? null : resolveRef(parentRaw, bindings),
|
||||
data: parseJsonArg(dataStr),
|
||||
}
|
||||
}
|
||||
|
||||
function parseUpdateArgs(
|
||||
argsStr: string,
|
||||
bindings: Map<string, string>,
|
||||
): { path: string; data: Record<string, any> } {
|
||||
const firstComma = findTopLevelComma(argsStr)
|
||||
if (firstComma === -1)
|
||||
throw new Error('Update requires path and update data')
|
||||
const pathRaw = argsStr.slice(0, firstComma).trim()
|
||||
const dataStr = argsStr.slice(firstComma + 1).trim()
|
||||
const path = resolvePathExpr(pathRaw, bindings)
|
||||
return { path, data: parseJsonArg(dataStr) }
|
||||
}
|
||||
|
||||
function parseReplaceArgs(
|
||||
argsStr: string,
|
||||
bindings: Map<string, string>,
|
||||
): { path: string; data: Record<string, any> } {
|
||||
const firstComma = findTopLevelComma(argsStr)
|
||||
if (firstComma === -1) throw new Error('Replace requires path and node data')
|
||||
const pathRaw = argsStr.slice(0, firstComma).trim()
|
||||
const dataStr = argsStr.slice(firstComma + 1).trim()
|
||||
const path = resolvePathExpr(pathRaw, bindings)
|
||||
return { path, data: parseJsonArg(dataStr) }
|
||||
}
|
||||
|
||||
function parseMoveArgs(
|
||||
argsStr: string,
|
||||
bindings: Map<string, string>,
|
||||
): { nodeId: string; parent: string | null; index?: number } {
|
||||
const parts = splitTopLevel(argsStr)
|
||||
if (parts.length < 2) throw new Error('Move requires nodeId and parent')
|
||||
return {
|
||||
nodeId: resolveRef(parts[0].trim(), bindings),
|
||||
parent:
|
||||
parts[1].trim() === 'null' || parts[1].trim() === 'undefined'
|
||||
? null
|
||||
: resolveRef(parts[1].trim(), bindings),
|
||||
index: parts[2] ? parseInt(parts[2].trim(), 10) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function resolveRef(raw: string, bindings: Map<string, string>): string {
|
||||
const cleaned = raw.replace(/^"|"$/g, '')
|
||||
return bindings.get(cleaned) ?? cleaned
|
||||
}
|
||||
|
||||
/** Resolve path expressions like `binding+"/child"` or `"id"` */
|
||||
function resolvePathExpr(
|
||||
raw: string,
|
||||
bindings: Map<string, string>,
|
||||
): string {
|
||||
if (raw.includes('+')) {
|
||||
return raw
|
||||
.split('+')
|
||||
.map((p) => {
|
||||
const t = p.trim()
|
||||
if (t.startsWith('"') || t.startsWith("'")) {
|
||||
return t.slice(1, -1)
|
||||
}
|
||||
return bindings.get(t) ?? t
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
const cleaned = raw.replace(/^"|"$/g, '')
|
||||
return bindings.get(cleaned) ?? cleaned
|
||||
}
|
||||
|
||||
/** Resolve slash-separated path (e.g. "instanceId/childId") to find the actual node. */
|
||||
function resolveSlashPath(
|
||||
path: string,
|
||||
_doc: PenDocument,
|
||||
_bindings: Map<string, string>,
|
||||
): string {
|
||||
// The path may be "parentId/childId" — we return as-is for findNodeByPath
|
||||
return path
|
||||
}
|
||||
|
||||
function findNodeByPath(path: string, doc: PenDocument): PenNode | undefined {
|
||||
const parts = path.split('/')
|
||||
if (parts.length === 1) {
|
||||
return findNodeInTree(doc.children, parts[0])
|
||||
}
|
||||
// For paths like "instanceId/childId", resolve through the tree
|
||||
// First find the instance, then look for child
|
||||
let current = findNodeInTree(doc.children, parts[0])
|
||||
for (let i = 1; i < parts.length && current; i++) {
|
||||
if ('children' in current && current.children) {
|
||||
current = current.children.find((c) => c.id === parts[i])
|
||||
} else {
|
||||
// For ref nodes, the child might be in the referenced component
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function findParentByPath(
|
||||
path: string,
|
||||
doc: PenDocument,
|
||||
): PenNode | undefined {
|
||||
const parts = path.split('/')
|
||||
if (parts.length <= 1) {
|
||||
// Top-level or simple ID — find parent in tree
|
||||
return findParentInTree(doc.children, parts[0])
|
||||
}
|
||||
// Parent is the second-to-last part
|
||||
const parentPath = parts.slice(0, -1).join('/')
|
||||
return findNodeByPath(parentPath, doc)
|
||||
}
|
||||
|
||||
function findParentInTree(
|
||||
nodes: PenNode[],
|
||||
id: string,
|
||||
): PenNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if ('children' in node && node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.id === id) return node
|
||||
}
|
||||
const found = findParentInTree(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function applyDescendantOverrides(
|
||||
node: PenNode,
|
||||
descendants: Record<string, any>,
|
||||
): void {
|
||||
if (!('children' in node) || !node.children) return
|
||||
for (const child of node.children) {
|
||||
const override = descendants[child.id]
|
||||
if (override) {
|
||||
Object.assign(child, override)
|
||||
}
|
||||
applyDescendantOverrides(child, descendants)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a JSON-like argument, handling unquoted keys. */
|
||||
function parseJsonArg(str: string): Record<string, any> {
|
||||
let normalized = str.trim()
|
||||
// Convert JavaScript-style object to JSON: unquoted keys → quoted
|
||||
normalized = normalized.replace(
|
||||
/(?<=\{|,)\s*(\w+)\s*:/g,
|
||||
' "$1":',
|
||||
)
|
||||
// Handle single-quoted strings → double-quoted
|
||||
normalized = normalized.replace(/'/g, '"')
|
||||
try {
|
||||
return JSON.parse(normalized)
|
||||
} catch {
|
||||
throw new Error(`Failed to parse JSON: ${str}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the index of the first comma not inside braces/brackets/quotes. */
|
||||
function findTopLevelComma(str: string): number {
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let quote = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i]
|
||||
if (inString) {
|
||||
if (ch === '\\') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === quote) inString = false
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = true
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++
|
||||
if (ch === '}' || ch === ']' || ch === ')') depth--
|
||||
if (ch === ',' && depth === 0) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function splitTopLevel(str: string): string[] {
|
||||
const result: string[] = []
|
||||
let start = 0
|
||||
let depth = 0
|
||||
let inString = false
|
||||
let quote = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i]
|
||||
if (inString) {
|
||||
if (ch === '\\') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === quote) inString = false
|
||||
continue
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = true
|
||||
quote = ch
|
||||
continue
|
||||
}
|
||||
if (ch === '{' || ch === '[' || ch === '(') depth++
|
||||
if (ch === '}' || ch === ']' || ch === ')') depth--
|
||||
if (ch === ',' && depth === 0) {
|
||||
result.push(str.slice(start, i))
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
result.push(str.slice(start))
|
||||
return result
|
||||
}
|
||||
|
||||
function countNodes(nodes: PenNode[]): number {
|
||||
let count = 0
|
||||
for (const node of nodes) {
|
||||
count++
|
||||
if ('children' in node && node.children) {
|
||||
count += countNodes(node.children)
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
89
src/mcp/tools/batch-get.ts
Normal file
89
src/mcp/tools/batch-get.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { openDocument } from '../document-manager'
|
||||
import {
|
||||
findNodeInTree,
|
||||
searchNodes,
|
||||
readNodeWithDepth,
|
||||
} from '../utils/node-operations'
|
||||
import type { PenNode } from '../../types/pen'
|
||||
|
||||
export interface SearchPattern {
|
||||
type?: string
|
||||
name?: string
|
||||
reusable?: boolean
|
||||
}
|
||||
|
||||
export interface BatchGetParams {
|
||||
filePath: string
|
||||
patterns?: SearchPattern[]
|
||||
nodeIds?: string[]
|
||||
parentId?: string
|
||||
readDepth?: number
|
||||
searchDepth?: number
|
||||
}
|
||||
|
||||
export async function handleBatchGet(
|
||||
params: BatchGetParams,
|
||||
): Promise<{ nodes: Record<string, unknown>[] }> {
|
||||
const filePath = resolve(params.filePath)
|
||||
const doc = await openDocument(filePath)
|
||||
|
||||
const readDepth = params.readDepth ?? 1
|
||||
const searchDepth = params.searchDepth ?? Infinity
|
||||
|
||||
// If no patterns or nodeIds, return top-level children
|
||||
if (!params.patterns?.length && !params.nodeIds?.length) {
|
||||
const rootNodes = params.parentId
|
||||
? (() => {
|
||||
const parent = findNodeInTree(doc.children, params.parentId)
|
||||
return parent && 'children' in parent && parent.children
|
||||
? parent.children
|
||||
: []
|
||||
})()
|
||||
: doc.children
|
||||
return {
|
||||
nodes: rootNodes.map((n) => readNodeWithDepth(n, readDepth)),
|
||||
}
|
||||
}
|
||||
|
||||
const results: PenNode[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
// Search by patterns
|
||||
if (params.patterns?.length) {
|
||||
const searchRoot = params.parentId
|
||||
? (() => {
|
||||
const parent = findNodeInTree(doc.children, params.parentId)
|
||||
return parent && 'children' in parent && parent.children
|
||||
? parent.children
|
||||
: []
|
||||
})()
|
||||
: doc.children
|
||||
|
||||
for (const pattern of params.patterns) {
|
||||
const found = searchNodes(searchRoot, pattern, searchDepth)
|
||||
for (const node of found) {
|
||||
if (!seen.has(node.id)) {
|
||||
seen.add(node.id)
|
||||
results.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read by IDs
|
||||
if (params.nodeIds?.length) {
|
||||
for (const id of params.nodeIds) {
|
||||
if (seen.has(id)) continue
|
||||
const node = findNodeInTree(doc.children, id)
|
||||
if (node) {
|
||||
seen.add(id)
|
||||
results.push(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: results.map((n) => readNodeWithDepth(n, readDepth)),
|
||||
}
|
||||
}
|
||||
64
src/mcp/tools/find-empty-space.ts
Normal file
64
src/mcp/tools/find-empty-space.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { openDocument } from '../document-manager'
|
||||
import { getNodeBounds, findNodeInTree } from '../utils/node-operations'
|
||||
import type { PenNode } from '../../types/pen'
|
||||
|
||||
export interface FindEmptySpaceParams {
|
||||
filePath: string
|
||||
width: number
|
||||
height: number
|
||||
padding?: number
|
||||
direction: 'top' | 'right' | 'bottom' | 'left'
|
||||
nodeId?: string
|
||||
}
|
||||
|
||||
export interface FindEmptySpaceResult {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export async function handleFindEmptySpace(
|
||||
params: FindEmptySpaceParams,
|
||||
): Promise<FindEmptySpaceResult> {
|
||||
const filePath = resolve(params.filePath)
|
||||
const doc = await openDocument(filePath)
|
||||
const padding = params.padding ?? 50
|
||||
|
||||
// Compute bounding box of reference content
|
||||
let nodes: PenNode[]
|
||||
if (params.nodeId) {
|
||||
const node = findNodeInTree(doc.children, params.nodeId)
|
||||
if (!node) throw new Error(`Node not found: ${params.nodeId}`)
|
||||
nodes = [node]
|
||||
} else {
|
||||
nodes = doc.children
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
// Compute combined bounding box
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const node of nodes) {
|
||||
const b = getNodeBounds(node, doc.children)
|
||||
minX = Math.min(minX, b.x)
|
||||
minY = Math.min(minY, b.y)
|
||||
maxX = Math.max(maxX, b.x + b.w)
|
||||
maxY = Math.max(maxY, b.y + b.h)
|
||||
}
|
||||
|
||||
switch (params.direction) {
|
||||
case 'right':
|
||||
return { x: maxX + padding, y: minY }
|
||||
case 'left':
|
||||
return { x: minX - padding - params.width, y: minY }
|
||||
case 'bottom':
|
||||
return { x: minX, y: maxY + padding }
|
||||
case 'top':
|
||||
return { x: minX, y: minY - padding - params.height }
|
||||
}
|
||||
}
|
||||
57
src/mcp/tools/open-document.ts
Normal file
57
src/mcp/tools/open-document.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { resolve } from 'node:path'
|
||||
import {
|
||||
openDocument,
|
||||
createEmptyDocument,
|
||||
saveDocument,
|
||||
fileExists,
|
||||
} from '../document-manager'
|
||||
import type { PenDocument } from '../../types/pen'
|
||||
|
||||
export interface OpenDocumentParams {
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export interface OpenDocumentResult {
|
||||
filePath: string
|
||||
document: {
|
||||
version: string
|
||||
name?: string
|
||||
childCount: number
|
||||
hasVariables: boolean
|
||||
hasThemes: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleOpenDocument(
|
||||
params: OpenDocumentParams,
|
||||
): Promise<OpenDocumentResult> {
|
||||
let filePath: string
|
||||
let doc: PenDocument
|
||||
|
||||
if (params.filePath) {
|
||||
filePath = resolve(params.filePath)
|
||||
const exists = await fileExists(filePath)
|
||||
if (exists) {
|
||||
doc = await openDocument(filePath)
|
||||
} else {
|
||||
// Create new file at specified path
|
||||
doc = createEmptyDocument()
|
||||
await saveDocument(filePath, doc)
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'filePath is required. Provide a path to an existing .op file or a new file to create.',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
document: {
|
||||
version: doc.version,
|
||||
name: doc.name,
|
||||
childCount: doc.children.length,
|
||||
hasVariables: !!doc.variables && Object.keys(doc.variables).length > 0,
|
||||
hasThemes: !!doc.themes && Object.keys(doc.themes).length > 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
44
src/mcp/tools/snapshot-layout.ts
Normal file
44
src/mcp/tools/snapshot-layout.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { openDocument } from '../document-manager'
|
||||
import { computeLayoutTree, type LayoutEntry } from '../utils/node-operations'
|
||||
|
||||
export interface SnapshotLayoutParams {
|
||||
filePath: string
|
||||
parentId?: string
|
||||
maxDepth?: number
|
||||
}
|
||||
|
||||
export async function handleSnapshotLayout(
|
||||
params: SnapshotLayoutParams,
|
||||
): Promise<{ layout: LayoutEntry[] }> {
|
||||
const filePath = resolve(params.filePath)
|
||||
const doc = await openDocument(filePath)
|
||||
|
||||
const maxDepth = params.maxDepth ?? 1
|
||||
|
||||
let nodes = doc.children
|
||||
if (params.parentId) {
|
||||
const findNode = (
|
||||
list: typeof nodes,
|
||||
id: string,
|
||||
): (typeof nodes)[0] | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.id === id) return n
|
||||
if ('children' in n && n.children) {
|
||||
const found = findNode(n.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const parent = findNode(doc.children, params.parentId)
|
||||
if (!parent) {
|
||||
throw new Error(`Node not found: ${params.parentId}`)
|
||||
}
|
||||
nodes =
|
||||
'children' in parent && parent.children ? parent.children : []
|
||||
}
|
||||
|
||||
const layout = computeLayoutTree(nodes, doc.children, maxDepth)
|
||||
return { layout }
|
||||
}
|
||||
40
src/mcp/tools/variables.ts
Normal file
40
src/mcp/tools/variables.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { resolve } from 'node:path'
|
||||
import { openDocument, saveDocument } from '../document-manager'
|
||||
import type { VariableDefinition } from '../../types/variables'
|
||||
|
||||
export interface GetVariablesParams {
|
||||
filePath: string
|
||||
}
|
||||
|
||||
export interface SetVariablesParams {
|
||||
filePath: string
|
||||
variables: Record<string, VariableDefinition>
|
||||
replace?: boolean
|
||||
}
|
||||
|
||||
export async function handleGetVariables(
|
||||
params: GetVariablesParams,
|
||||
): Promise<{ variables: Record<string, VariableDefinition>; themes: Record<string, string[]> }> {
|
||||
const filePath = resolve(params.filePath)
|
||||
const doc = await openDocument(filePath)
|
||||
return {
|
||||
variables: doc.variables ?? {},
|
||||
themes: doc.themes ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSetVariables(
|
||||
params: SetVariablesParams,
|
||||
): Promise<{ variables: Record<string, VariableDefinition> }> {
|
||||
const filePath = resolve(params.filePath)
|
||||
const doc = await openDocument(filePath)
|
||||
|
||||
if (params.replace) {
|
||||
doc.variables = params.variables
|
||||
} else {
|
||||
doc.variables = { ...(doc.variables ?? {}), ...params.variables }
|
||||
}
|
||||
|
||||
await saveDocument(filePath, doc)
|
||||
return { variables: doc.variables }
|
||||
}
|
||||
5
src/mcp/utils/id.ts
Normal file
5
src/mcp/utils/id.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
|
||||
export function generateId(): string {
|
||||
return nanoid()
|
||||
}
|
||||
250
src/mcp/utils/node-operations.ts
Normal file
250
src/mcp/utils/node-operations.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import type { PenNode, RefNode } from '../../types/pen'
|
||||
|
||||
export function findNodeInTree(
|
||||
nodes: PenNode[],
|
||||
id: string,
|
||||
): PenNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if ('children' in node && node.children) {
|
||||
const found = findNodeInTree(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function findParentInTree(
|
||||
nodes: PenNode[],
|
||||
id: string,
|
||||
): PenNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if ('children' in node && node.children) {
|
||||
for (const child of node.children) {
|
||||
if (child.id === id) return node
|
||||
}
|
||||
const found = findParentInTree(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
|
||||
return nodes
|
||||
.filter((n) => n.id !== id)
|
||||
.map((n) => {
|
||||
if ('children' in n && n.children) {
|
||||
return { ...n, children: removeNodeFromTree(n.children, id) }
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNodeInTree(
|
||||
nodes: PenNode[],
|
||||
id: string,
|
||||
updates: Partial<PenNode>,
|
||||
): PenNode[] {
|
||||
return nodes.map((n) => {
|
||||
if (n.id === id) {
|
||||
return { ...n, ...updates } as PenNode
|
||||
}
|
||||
if ('children' in n && n.children) {
|
||||
return {
|
||||
...n,
|
||||
children: updateNodeInTree(n.children, id, updates),
|
||||
} as PenNode
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
export function insertNodeInTree(
|
||||
nodes: PenNode[],
|
||||
parentId: string | null,
|
||||
node: PenNode,
|
||||
index?: number,
|
||||
): PenNode[] {
|
||||
if (parentId === null) {
|
||||
const arr = [...nodes]
|
||||
if (index !== undefined) {
|
||||
arr.splice(index, 0, node)
|
||||
} else {
|
||||
arr.push(node)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
if (n.id === parentId) {
|
||||
const children = 'children' in n && n.children ? [...n.children] : []
|
||||
if (index !== undefined) {
|
||||
children.splice(index, 0, node)
|
||||
} else {
|
||||
children.push(node)
|
||||
}
|
||||
return { ...n, children } as PenNode
|
||||
}
|
||||
if ('children' in n && n.children) {
|
||||
return {
|
||||
...n,
|
||||
children: insertNodeInTree(n.children, parentId, node, index),
|
||||
} as PenNode
|
||||
}
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
export function flattenNodes(nodes: PenNode[]): PenNode[] {
|
||||
const result: PenNode[] = []
|
||||
for (const node of nodes) {
|
||||
result.push(node)
|
||||
if ('children' in node && node.children) {
|
||||
result.push(...flattenNodes(node.children))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function cloneNodeWithNewIds(
|
||||
node: PenNode,
|
||||
generateId: () => string,
|
||||
): PenNode {
|
||||
const cloned = { ...node, id: generateId() } as PenNode
|
||||
if ('children' in cloned && cloned.children) {
|
||||
cloned.children = cloned.children.map((c) =>
|
||||
cloneNodeWithNewIds(c, generateId),
|
||||
)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
/** Get the bounding box of a node, resolving RefNode dimensions from component. */
|
||||
export function getNodeBounds(
|
||||
node: PenNode,
|
||||
allNodes: PenNode[],
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const x = node.x ?? 0
|
||||
const y = node.y ?? 0
|
||||
let w = 'width' in node && typeof node.width === 'number' ? node.width : 0
|
||||
let h = 'height' in node && typeof node.height === 'number' ? node.height : 0
|
||||
if (node.type === 'ref' && !w) {
|
||||
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
|
||||
if (refComp) {
|
||||
w =
|
||||
'width' in refComp && typeof refComp.width === 'number'
|
||||
? refComp.width
|
||||
: 100
|
||||
h =
|
||||
'height' in refComp && typeof refComp.height === 'number'
|
||||
? refComp.height
|
||||
: 100
|
||||
}
|
||||
}
|
||||
return { x, y, w: w || 100, h: h || 100 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Search nodes matching a pattern. Supports:
|
||||
* - type: exact match on node type
|
||||
* - name: regex match on node name
|
||||
* - reusable: match reusable flag
|
||||
*/
|
||||
export function searchNodes(
|
||||
nodes: PenNode[],
|
||||
pattern: { type?: string; name?: string; reusable?: boolean },
|
||||
maxDepth = Infinity,
|
||||
currentDepth = 0,
|
||||
): PenNode[] {
|
||||
if (currentDepth > maxDepth) return []
|
||||
const results: PenNode[] = []
|
||||
for (const node of nodes) {
|
||||
let matches = true
|
||||
if (pattern.type && node.type !== pattern.type) matches = false
|
||||
if (pattern.name) {
|
||||
const regex = new RegExp(pattern.name, 'i')
|
||||
if (!regex.test(node.name ?? '')) matches = false
|
||||
}
|
||||
if (pattern.reusable !== undefined) {
|
||||
const isReusable = 'reusable' in node && (node as any).reusable === true
|
||||
if (pattern.reusable !== isReusable) matches = false
|
||||
}
|
||||
if (matches) results.push(node)
|
||||
if ('children' in node && node.children) {
|
||||
results.push(
|
||||
...searchNodes(node.children, pattern, maxDepth, currentDepth + 1),
|
||||
)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** Read a node with depth-limited children. */
|
||||
export function readNodeWithDepth(
|
||||
node: PenNode,
|
||||
depth: number,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...node }
|
||||
if (depth <= 0 && 'children' in node && node.children?.length) {
|
||||
result.children = '...'
|
||||
} else if ('children' in node && node.children) {
|
||||
result.children = node.children.map((c) =>
|
||||
readNodeWithDepth(c, depth - 1),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Compute bounding box layout tree for snapshot_layout. */
|
||||
export function computeLayoutTree(
|
||||
nodes: PenNode[],
|
||||
allNodes: PenNode[],
|
||||
maxDepth: number,
|
||||
currentDepth = 0,
|
||||
parentX = 0,
|
||||
parentY = 0,
|
||||
): LayoutEntry[] {
|
||||
const entries: LayoutEntry[] = []
|
||||
for (const node of nodes) {
|
||||
const bounds = getNodeBounds(node, allNodes)
|
||||
const absX = parentX + bounds.x
|
||||
const absY = parentY + bounds.y
|
||||
const entry: LayoutEntry = {
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
x: absX,
|
||||
y: absY,
|
||||
width: bounds.w,
|
||||
height: bounds.h,
|
||||
}
|
||||
if (
|
||||
'children' in node &&
|
||||
node.children?.length &&
|
||||
currentDepth < maxDepth
|
||||
) {
|
||||
entry.children = computeLayoutTree(
|
||||
node.children,
|
||||
allNodes,
|
||||
maxDepth,
|
||||
currentDepth + 1,
|
||||
absX,
|
||||
absY,
|
||||
)
|
||||
}
|
||||
entries.push(entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface LayoutEntry {
|
||||
id: string
|
||||
name?: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
children?: LayoutEntry[]
|
||||
}
|
||||
|
|
@ -1,70 +1,117 @@
|
|||
const PEN_NODE_SCHEMA = `
|
||||
PenNode types (the ONLY format you output for designs):
|
||||
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), children[], cornerRadius, fill, stroke, effects
|
||||
- frame: Container. Props: width, height, layout ('none'|'vertical'|'horizontal'), gap, padding, justifyContent ('start'|'center'|'end'|'space_between'|'space_around'), alignItems ('start'|'center'|'end'), clipContent (boolean, clips overflowing children), children[], cornerRadius, fill, stroke, effects
|
||||
- rectangle: Props: width, height, cornerRadius, fill, stroke, effects
|
||||
- ellipse: Props: width, height, fill, stroke, effects
|
||||
- text: Props: content (string), fontFamily, fontSize, fontWeight, fill, width, height, textAlign
|
||||
- text: Props: content (string), fontFamily, fontSize, fontWeight, fontStyle ('normal'|'italic'), fill, width, height, textAlign, textGrowth ('auto'|'fixed-width'|'fixed-width-height'), lineHeight (number, multiplier e.g. 1.2), letterSpacing (number, px), textAlignVertical ('top'|'middle'|'bottom')
|
||||
- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects. IMPORTANT: width and height must match the natural aspect ratio of the SVG path — do NOT force 1:1 for non-square icons/logos
|
||||
- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects
|
||||
|
||||
All nodes share: id (string), type, name, x, y, rotation, opacity
|
||||
|
||||
SIZING: width/height accept number (px), "fill_container" (stretch to fill parent), or "fit_content" (shrink to content).
|
||||
- In vertical layout: "fill_container" width = stretch horizontally, "fill_container" height = grow to fill remaining vertical space.
|
||||
- In horizontal layout: "fill_container" width = grow to fill remaining horizontal space, "fill_container" height = stretch vertically.
|
||||
- "fit_content" = shrink-wrap to the size of children content.
|
||||
PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80] for side padding), or [top, right, bottom, left].
|
||||
CLIP CONTENT: set clipContent: true on frames to clip children that overflow. Use with cornerRadius to prevent children from poking out of rounded corners. Essential for cards with images + cornerRadius.
|
||||
Fill = [{ type: "solid", color: "#hex" }] or [{ type: "linear_gradient", angle: number, stops: [{ offset: 0-1, color: "#hex" }] }]
|
||||
Stroke = { thickness: number, fill: [{ type: "solid", color: "#hex" }] }
|
||||
Effects = [{ type: "shadow", offsetX, offsetY, blur, spread, color }]
|
||||
|
||||
TEXT RESIZING (textGrowth):
|
||||
- "auto" = Auto Width: text expands horizontally, no word wrapping. Best for short labels, buttons, single-line text.
|
||||
- "fixed-width" = Auto Height: width is fixed (or "fill_container"), height auto-sizes to wrapped content. Best for paragraphs, descriptions, multi-line text.
|
||||
- "fixed-width-height" = Fixed Size: both width and height are fixed. Content clips if too long.
|
||||
- DEFAULT RULE: text inside layout frames should use textGrowth="fixed-width" + width="fill_container". This ensures text wraps within the parent and height auto-sizes.
|
||||
- Short labels/buttons can omit textGrowth (defaults to "auto").
|
||||
|
||||
TEXT TYPOGRAPHY:
|
||||
- lineHeight: multiplier (e.g. 1.2 = 120%). Defaults: display/heading 1.1-1.2, body 1.4-1.6, captions 1.3. Always set lineHeight on text nodes.
|
||||
- letterSpacing: px value. Defaults: 0 for body, -0.5 to -1 for large headlines (tighter), 0.5-2 for uppercase labels/captions (looser). Set when it improves readability.
|
||||
- textAlignVertical: 'top' (default), 'middle', 'bottom'. Use 'middle' for text centered in fixed-height containers like buttons or badges.
|
||||
|
||||
RULES:
|
||||
- cornerRadius is a number, NOT an object
|
||||
- fill is ALWAYS an array
|
||||
- Children inside layout frames MUST have explicit numeric width and height
|
||||
- Do NOT set x/y on children inside layout frames — the engine positions them
|
||||
- Only set x/y on the ROOT frame
|
||||
- Use "fill_container" as width/height string for children that should stretch to fill their parent
|
||||
- Use "fill_container" to stretch, "fit_content" to shrink-wrap
|
||||
- Use clipContent: true on cards/containers with cornerRadius + image children to prevent overflow
|
||||
- Use justifyContent="space_between" to spread items across full width (great for navbars, footers)
|
||||
|
||||
OVERFLOW PREVENTION (CRITICAL — violations cause visual glitches):
|
||||
- TEXT WIDTH: text nodes inside layout frames MUST use width="fill_container" + textGrowth="fixed-width". NEVER set a fixed pixel width on text inside a layout — it WILL overflow. The layout engine auto-constrains fill_container text to the parent's available content area.
|
||||
BAD: {"type":"text","width":378,"textGrowth":"fixed-width"} inside a 195px card with 80px padding → overflows!
|
||||
GOOD: {"type":"text","width":"fill_container","textGrowth":"fixed-width"} → auto-fits to 115px available space.
|
||||
- CHILD SIZE: any child with a fixed pixel width must be ≤ parent's content area (parent width − total horizontal padding). If unsure, use "fill_container".
|
||||
- CJK TEXT (Chinese/Japanese/Korean): each character renders at ~1.0× fontSize width. For buttons/badges containing CJK text, ensure: container width ≥ (charCount × fontSize) + total horizontal padding. Example: "免费下载" (4 chars) at fontSize 15 → needs ~60px content + padding → button width ≥ 104px with padding [8,22].
|
||||
- BADGES: when badge text is long or CJK, use width="fit_content" on the badge frame so it auto-sizes to its text content.
|
||||
`
|
||||
|
||||
const DESIGN_EXAMPLES = `
|
||||
EXAMPLES:
|
||||
|
||||
Button with icon:
|
||||
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
|
||||
{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 48, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "padding": [12, 24], "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowRightIcon", "d": "M5 12h14m-7-7 7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] }
|
||||
|
||||
Card with image:
|
||||
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": 320, "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": 320, "height": 140, "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
|
||||
Card with image (clipContent prevents image from poking out of rounded corners):
|
||||
{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "clipContent": true, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": "fill_container", "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": "fill_container", "height": "fit_content", "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "lineHeight": 1.2, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "lineHeight": 1.5, "textGrowth": "fixed-width", "width": "fill_container", "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] }
|
||||
|
||||
ICONS & IMAGES:
|
||||
- Icons: Use "path" nodes with SVG d attribute. Use stroke for line icons, fill for solid icons. Size 16-24px for UI icons. IMPORTANT: width and height must match the SVG path's natural aspect ratio — symmetric icons like arrows are square, but brand logos (Apple, Meta, etc.) are often taller than wide or vice versa. Never force all icons to 1:1.
|
||||
- Images: Use "image" nodes. src = "https://picsum.photos/{width}/{height}" for placeholders. Set explicit width/height.
|
||||
- You know many Lucide icon SVG paths — use them freely. Always give icon nodes descriptive names.
|
||||
- Icons: Use "path" nodes with SVG d attribute. Size 16-24px for UI icons. Use stroke for line icons (Lucide-style), fill for solid icons.
|
||||
IMPORTANT: Give icon nodes a descriptive name matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "CheckIcon", "StarIcon", "DownloadIcon", "PlayIcon", "ShieldIcon", "ZapIcon", "HeartIcon", "UserIcon", "HomeIcon", "MailIcon", "BellIcon", "SettingsIcon", "PlusIcon", "EyeIcon", "LockIcon", "PhoneIcon", "ChevronRightIcon", "ChevronDownIcon", "XIcon").
|
||||
The system will auto-resolve icon names to verified SVG paths, so the name is more important than the d data.
|
||||
- Never use emoji characters as icons (e.g. 🧠✨📱✅). Always use path nodes for icons.
|
||||
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. No text inside — just a clean phone shape.
|
||||
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
|
||||
`
|
||||
|
||||
const INDUSTRIAL_DESIGN_SYSTEM = `
|
||||
INDUSTRIAL DESIGN SYSTEM (Dark / Technical / Terminal-inspired)
|
||||
const ADAPTIVE_STYLE_POLICY = `
|
||||
VISUAL STYLE POLICY:
|
||||
- Do NOT force a dark black+green palette unless the user explicitly asks for it.
|
||||
- Infer style from user intent and content:
|
||||
- If user requests dark/cyber/terminal, use dark themes.
|
||||
- Otherwise default to a clean light marketing style.
|
||||
|
||||
COLORS:
|
||||
- Page Bg: #16171B (Near-black)
|
||||
- Card Bg: #1E2026 (Dark charcoal)
|
||||
- Text Primary: #F4F4F5
|
||||
- Text Secondary: #52525B
|
||||
- Text Tertiary: #71717A
|
||||
- Accent Primary: #22C55E (Terminal Green - Active/Success)
|
||||
- Accent Warning: #F59E0B (Amber)
|
||||
- Borders: #2A2B30 (Standard), #22C55E (Active)
|
||||
DEFAULT LIGHT PALETTE (when no explicit style is requested):
|
||||
- Page Bg: #F8FAFC
|
||||
- Surface/Card: #FFFFFF
|
||||
- Text Primary: #0F172A
|
||||
- Text Secondary: #475569
|
||||
- Accent Primary: #2563EB
|
||||
- Accent Secondary: #0EA5E9
|
||||
- Border: #E2E8F0
|
||||
|
||||
TYPOGRAPHY:
|
||||
- Headlines: "Space Grotesk" (Bold 700)
|
||||
- Data/Labels: "Roboto Mono" (Systematic)
|
||||
- Body: "Inter"
|
||||
TYPOGRAPHY SCALE (always set lineHeight on text nodes):
|
||||
- Display: 40-56px (hero headlines) — "Space Grotesk" or "Manrope" (700), lineHeight: 1.1, letterSpacing: -0.5
|
||||
- Heading: 28-36px (section titles) — "Space Grotesk" or "Manrope" (600-700), lineHeight: 1.2
|
||||
- Subheading: 20-24px — "Inter" (600), lineHeight: 1.3
|
||||
- Body: 16-18px — "Inter" (400-500), lineHeight: 1.5
|
||||
- Caption: 13-14px — "Inter" (400), lineHeight: 1.4
|
||||
- Labels/Numbers: "Inter" or "Roboto Mono" as needed
|
||||
- Uppercase labels: letterSpacing: 1-2
|
||||
|
||||
SHAPES:
|
||||
- Corner Radius: 4px (Sharp, industrial)
|
||||
- Tab Bar: 100px (Pill shape)
|
||||
- Shadows: NONE (Use 1px borders)
|
||||
CJK TYPOGRAPHY (Chinese/Japanese/Korean content):
|
||||
- When the design content is in Chinese/Japanese/Korean, use CJK-compatible fonts:
|
||||
- Headings: "Noto Sans SC" (Chinese), "Noto Sans JP" (Japanese), "Noto Sans KR" (Korean)
|
||||
- Body/UI: "Inter" (has system CJK fallback) or "Noto Sans SC"
|
||||
- DO NOT use "Space Grotesk" or "Manrope" for CJK text — these fonts have NO CJK glyphs and will render inconsistently.
|
||||
- CJK lineHeight: use 1.3-1.4 for headings (not 1.1), 1.6-1.8 for body. CJK characters are taller and need more line spacing.
|
||||
- CJK letterSpacing: use 0 for body, 0.5-1 for headings. Do NOT use negative letterSpacing on CJK — it causes characters to overlap.
|
||||
- Detect language from the user's request content: if the prompt or product description is in Chinese/Japanese/Korean, use CJK fonts for ALL text nodes.
|
||||
|
||||
COMPONENTS:
|
||||
- Section Headers: "// SECTION_NAME" (Code comment style, Roboto Mono)
|
||||
- Cards: Flat, 1px border, #1E2026 bg, 4px radius
|
||||
- Status Indicators: Terminal green dots/dashes
|
||||
- Navigation: Pill-shaped bottom bar
|
||||
SHAPES & EFFECTS:
|
||||
- Corner Radius: 8-14 for modern product UI
|
||||
- Use subtle shadows when appropriate; avoid heavy glow by default
|
||||
- Keep hierarchy clear with spacing and contrast
|
||||
|
||||
LANDING PAGE DESIGN TIPS:
|
||||
- Hero sections: gradient or bold color backgrounds, large headline, generous whitespace (80-120px padding)
|
||||
- Section rhythm: alternate backgrounds for visual separation, 80-120px vertical padding per section
|
||||
- Cards: consistent corner radius (12-16px), clipContent: true, subtle shadows, grouped content
|
||||
- CTAs: bold accent color, generous padding (16-20px v, 32-48px h), clear action text
|
||||
- Centered content width ~1040-1160px across sections for alignment stability
|
||||
`
|
||||
|
||||
// Safe code block delimiter
|
||||
|
|
@ -82,50 +129,66 @@ NEVER output HTML, CSS, or React code — ONLY PenNode JSON.
|
|||
NEVER use tools, functions, or external calls. Design everything URSELF in the response.
|
||||
NEVER say "I will create..." or "Here is the design..." — START DIRECTLY WITH <step>.
|
||||
|
||||
PROCESS VISUALIZATION (Deep Agent Simulation):
|
||||
You MUST output your thought process as structured XML steps BEFORE the final JSON.
|
||||
Follow this EXACT sequence of steps:
|
||||
|
||||
1. <step title="Checking guidelines">
|
||||
[Analyze the request against the Industrial Design System. Quote specific rules you will follow.]
|
||||
</step>
|
||||
|
||||
2. <step title="Getting editor state">
|
||||
[Simulate checking the context. Mention "pencil-new.pen" and "No reusable components found".]
|
||||
</step>
|
||||
|
||||
3. <step title="Picked a styleguide">
|
||||
[Briefly summarize the chosen style: "Industrial × Technical Mobile Dashboard". Mention "Space Grotesk", "Roboto Mono", and "Terminal Green".]
|
||||
</step>
|
||||
|
||||
4. <step title="Design">
|
||||
[The final generation step. Describe the high-level layout structure you are building: "Building layout with sidebar, header, and KPI section..."]
|
||||
</step>
|
||||
You may include 1-2 brief <step> tags before the JSON (optional, keep them SHORT — one line each).
|
||||
Start generating JSON as quickly as possible — minimize preamble.
|
||||
|
||||
When a user asks non-design questions (explain, suggest colors, give advice), respond in text.
|
||||
|
||||
${INDUSTRIAL_DESIGN_SYSTEM}
|
||||
${ADAPTIVE_STYLE_POLICY}
|
||||
|
||||
${DESIGN_EXAMPLES}
|
||||
|
||||
LAYOUT ENGINE:
|
||||
LAYOUT ENGINE (flexbox-based):
|
||||
- Frames with layout: "vertical"/"horizontal" auto-position children via gap, padding, justifyContent, alignItems
|
||||
- Do NOT set x/y on children inside layout containers
|
||||
- Every child in a layout frame MUST have explicit numeric width and height
|
||||
- NEVER set x/y on children inside layout containers — the engine positions them automatically
|
||||
- CHILD SIZE RULE: child width must be ≤ parent content area. Use "fill_container" when in doubt.
|
||||
- SIZING: width/height accept: number (px), "fill_container" (stretch to fill parent), "fit_content" (shrink-wrap to content size).
|
||||
In vertical layout: "fill_container" width stretches horizontally; "fill_container" height fills remaining vertical space.
|
||||
In horizontal layout: "fill_container" width fills remaining horizontal space; "fill_container" height stretches vertically.
|
||||
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80]), or [top, right, bottom, left].
|
||||
- CLIP CONTENT: set clipContent: true to clip children that overflow the frame. ALWAYS use on cards with cornerRadius + image children.
|
||||
- FLEX DISTRIBUTION via justifyContent:
|
||||
"space_between" = push items to edges with equal gaps between (ideal for navbars: logo | links | CTA)
|
||||
"space_around" = equal space around each item
|
||||
"center" = center-pack items
|
||||
"start"/"end" = pack to start/end
|
||||
- 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 input/button uses "fill_container", ALL sibling inputs/buttons must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
|
||||
- NEVER use "fill_container" on children of a "fit_content" parent — circular dependency breaks layout.
|
||||
- For two-column layouts: root (vertical) → content row (horizontal) → left column + right column. Each column uses "fill_container" width.
|
||||
- TEXT IN LAYOUTS (MOST COMMON BUG — read carefully): text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use a fixed pixel width (e.g. width:378) on text inside a layout — the text WILL overflow its parent. "fill_container" auto-constrains to available space.
|
||||
- SHORT TEXT: buttons, labels, single-line text can use textGrowth="auto" (or omit it) — text expands horizontally to fit content.
|
||||
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely — the layout engine auto-calculates height from textGrowth + content. Setting a small height causes text clipping and overlap with siblings below.
|
||||
- CJK BUTTONS/BADGES: Chinese/Japanese/Korean characters are wider. For a button with CJK text, ensure container width ≥ (charCount × fontSize) + horizontal padding. Example: "免费下载" (4 chars) at 15px → min content width ~60px → button width ≥ 60 + left padding + right padding.
|
||||
- Use nested frames for complex layouts
|
||||
|
||||
DESIGN GUIDELINES:
|
||||
- Mobile screens: root frame 375x812 at x:0,y:0. Web: 1200x800
|
||||
- Mobile screens: 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 — no floating elements
|
||||
- For web pages, use a consistent centered content container (~1040-1160px) across sections to keep alignment stable
|
||||
- Max 3-4 levels of nesting
|
||||
- Text: titles 22-28px bold, body 14-16px, captions 12px
|
||||
- Buttons: height 44-48px, cornerRadius 8-12
|
||||
- Inputs: height 44px, light bg, subtle border
|
||||
- Buttons: height 44-52px, cornerRadius 8-12, padding [12, 24] (vertical, horizontal). With icon+text: layout="horizontal", gap=8, alignItems="center". Width: "fill_container" (stretch), "fit_content" (hug), or fixed px — choose per context.
|
||||
- Icon-only buttons (heart, bookmark, share, etc.): square frame 44x44px, justifyContent="center", alignItems="center", path icon 20-24px inside.
|
||||
- Badges/tags ("NEW", "SALE", "PRO"): frame with padding [4, 12], cornerRadius 4-6, height="fit_content". Small text (11-13px), no textGrowth. Never clip badge text.
|
||||
- Button + icon-button row: horizontal, gap=8-12. Primary button width="fill_container"; icon-only button fixed square 44-48px.
|
||||
- Inputs: height 44px, light bg, subtle border. Use width="fill_container" in form contexts.
|
||||
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
|
||||
- Consistent color palette
|
||||
- Use path nodes for icons (SVG d path data). Size icons 16-24px. Preserve the natural aspect ratio of the SVG path — do NOT force all icons to square
|
||||
- Use image nodes for photos/illustrations with picsum.photos placeholder URLs
|
||||
- Default to light neutral styling unless user explicitly asks for dark/neon/terminal
|
||||
- Avoid repeating the exact same palette across unrelated designs
|
||||
- Navigation bars (when designing landing pages/websites): use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
|
||||
- Icons: use "path" nodes with descriptive names matching standard icon names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon"). The system auto-resolves names to verified SVG paths. Size 16-24px.
|
||||
- Never use emoji glyphs as icon substitutes. If an icon is needed, use a path node with a descriptive icon name.
|
||||
- Use image nodes for generic photos/illustrations only; for app preview areas prefer phone mockup placeholders
|
||||
- Phone mockup/screenshot placeholder: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill matching theme + 1px subtle stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). All mockups must look identical.
|
||||
- NEVER use ellipse nodes for decorative/placeholder shapes. Use frame or rectangle with cornerRadius instead.
|
||||
- Avoid adding an extra full-width CTA strip directly under navigation unless the prompt explicitly asks for that section.
|
||||
- Buttons, nav items, and list items should include icons when appropriate for better UX
|
||||
- Long subtitles/body copy should use fixed-width text blocks so lines wrap naturally instead of becoming a single very long line.
|
||||
- CARD ROW ALIGNMENT: when cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row. Never use different fixed heights on sibling cards.
|
||||
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows. Only omit textGrowth for very short labels (1-3 words) like button text or nav links.
|
||||
|
||||
DESIGN VARIABLES:
|
||||
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
|
||||
|
|
@ -133,70 +196,84 @@ DESIGN VARIABLES:
|
|||
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
|
||||
- Only reference variables that are listed — do NOT invent new variable names.`
|
||||
|
||||
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON.
|
||||
export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON streaming engine. Convert design descriptions into flat PenNode JSON, one element at a time.
|
||||
|
||||
${PEN_NODE_SCHEMA}
|
||||
|
||||
OUTPUT FORMAT:
|
||||
1. Start immediately with <step title="Checking guidelines">...</step>.
|
||||
2. You may include additional <step ...>...</step> lines.
|
||||
3. Output progressive JSON blocks in sequence:
|
||||
- Phase 1 (Structure): one ${BLOCK}json block with root frame + main layout skeleton.
|
||||
- Phase 2 (Content): one ${BLOCK}json block that uses the SAME IDs and fills content/details.
|
||||
- Phase 3 (Refine, optional): one ${BLOCK}json block for minor style/spacing fixes.
|
||||
4. Add a 1-2 sentence summary after the final JSON block.
|
||||
OUTPUT FORMAT — ELEMENT-BY-ELEMENT STREAMING:
|
||||
Each element is rendered to the canvas the INSTANT it finishes generating. Output flat JSON objects inside a single ${BLOCK}json block.
|
||||
|
||||
STEP 1 — PLAN (required):
|
||||
List ALL planned sections as <step> tags BEFORE the json block:
|
||||
<step title="Navigation bar"></step>
|
||||
<step title="Hero section"></step>
|
||||
<step title="Feature cards"></step>
|
||||
|
||||
STEP 2 — BUILD:
|
||||
Output a ${BLOCK}json block containing flat JSON objects, ONE PER LINE.
|
||||
Every node MUST have a "_parent" field:
|
||||
- Root frame: "_parent": null
|
||||
- All others: "_parent": "<parent-id>"
|
||||
|
||||
Output parent nodes BEFORE their children (depth-first order).
|
||||
Each line = one complete JSON object. NO multi-line formatting. NO nested "children" arrays.
|
||||
|
||||
EXAMPLE:
|
||||
<step title="Page structure"></step>
|
||||
<step title="Navigation"></step>
|
||||
<step title="Hero"></step>
|
||||
|
||||
${BLOCK}json
|
||||
{"_parent":null,"id":"page","type":"frame","name":"Page","x":0,"y":0,"width":375,"height":812,"layout":"vertical","gap":0,"fill":[{"type":"solid","color":"#F8FAFC"}]}
|
||||
{"_parent":"page","id":"nav","type":"frame","name":"Nav","width":"fill_container","height":56,"layout":"horizontal","padding":16,"alignItems":"center","fill":[{"type":"solid","color":"#FFFFFF"}]}
|
||||
{"_parent":"nav","id":"logo","type":"text","name":"Logo","content":"App","fontSize":18,"fontWeight":700,"fill":[{"type":"solid","color":"#0F172A"}]}
|
||||
{"_parent":"nav","id":"menu-icon","type":"path","name":"MenuIcon","d":"M4 6h16M4 12h16M4 18h16","width":24,"height":24,"stroke":{"thickness":2,"fill":[{"type":"solid","color":"#0F172A"}]}}
|
||||
{"_parent":"page","id":"hero","type":"frame","name":"Hero","width":"fill_container","height":300,"layout":"vertical","padding":24,"gap":16,"alignItems":"center","justifyContent":"center"}
|
||||
{"_parent":"hero","id":"title","type":"text","name":"Title","content":"Welcome","fontSize":28,"fontWeight":700,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#0F172A"}]}
|
||||
${BLOCK}
|
||||
|
||||
CRITICAL RULES:
|
||||
- Progressive generation is required when possible (at least 2 JSON blocks for UI screens).
|
||||
- Reuse the same node IDs across phases so partial upsert can update existing nodes.
|
||||
- Each later phase should be more complete than the previous one.
|
||||
- After Phase 1, do NOT move already placed sections unless explicitly fixing overlap.
|
||||
- Keep root frame x/y/width/height stable across phases.
|
||||
- Use a single root frame containing ALL elements as children.
|
||||
- Keep IDs unique and stable across all phases.
|
||||
- DO NOT WRITE ANY INTRODUCTORY TEXT.
|
||||
- DO NOT use nested "children" arrays — each node is a FLAT JSON object with "_parent".
|
||||
- ONE JSON object per line — never split a node across lines.
|
||||
- Output parent before children (depth-first).
|
||||
- Root frame: "_parent": null, x:0, y:0.
|
||||
- NEVER set x/y on children inside layout frames — the layout engine positions them automatically.
|
||||
- ALL nodes must be descendants of the root frame — no floating/orphan elements.
|
||||
- Section frames must use width="fill_container" to span full page width.
|
||||
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input uses "fill_container", ALL sibling inputs/buttons in that container must also use "fill_container". Never mix fixed-px and fill_container in form layouts.
|
||||
- NEVER use "fill_container" on children of "fit_content" parent — circular dependency breaks layout.
|
||||
- For two-column content: use a horizontal frame parent with two child frames.
|
||||
- Use clipContent: true on cards/containers with cornerRadius + image/overflow content. Essential for clean rounded corners.
|
||||
- Use width/height (or "fill_container") on all children. Unique descriptive IDs. All colors as fill arrays.
|
||||
- Start with <step> tags, then immediately the json block. NO preamble text.
|
||||
- After the json block, add a 1-sentence summary.
|
||||
- Phone mockup: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse. NEVER add any children inside (no text, no frames, no images). All mockups identical.
|
||||
- NEVER use ellipse for decorative/placeholder shapes — use frame or rectangle with cornerRadius.
|
||||
- Navigation bars (when applicable): justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
|
||||
- Never use emoji as icons; use path nodes with descriptive icon names (system auto-resolves to verified SVG paths).
|
||||
- TEXT IN LAYOUTS: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER use fixed pixel widths on text. Short labels/buttons can omit textGrowth.
|
||||
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22). OMIT the height property — the engine auto-calculates from textGrowth + content. A small explicit height causes text clipping and overlap.
|
||||
- Cards with images: ALWAYS set clipContent: true + cornerRadius. Use "fill_container" width on image/body/text children inside the card.
|
||||
- CARD ROW ALIGNMENT: cards in a horizontal row MUST ALL use width="fill_container" + height="fill_container" for even distribution and equal height. Never use different fixed heights on sibling cards — it creates an ugly uneven row.
|
||||
- TEXT WRAPPING: any text content longer than ~15 characters MUST have textGrowth="fixed-width" + width="fill_container". Without textGrowth, long text renders as ONE single line and overflows. Only omit textGrowth for very short labels (1-3 words).
|
||||
- Keep section rhythm consistent (80-120px vertical padding) and preserve alignment between sections.
|
||||
|
||||
DO NOT output bullet points, design descriptions, or explanations BEFORE the JSON (except <step> tags).
|
||||
DO NOT describe what you plan to create — just CREATE IT as JSON.
|
||||
DO NOT output HTML, CSS, or any code other than PenNode JSON.
|
||||
OVERFLOW PREVENTION (CRITICAL — #1 source of visual bugs):
|
||||
- ALL text nodes inside layout frames → width="fill_container" + textGrowth="fixed-width". No exceptions. NEVER width:378 or width:224 on text inside a layout frame.
|
||||
- Fixed-width children must be ≤ parent content area (parent width − horizontal padding). Example: a card width=195 with padding=[24,40,24,40] has 115px available — a child with width=378 causes severe overflow.
|
||||
- CJK (Chinese/Japanese/Korean) text in buttons: each CJK char ≈ fontSize wide. "免费下载" (4 chars) at fontSize 15 = ~60px minimum content width. Button must be ≥ 60 + horizontal padding.
|
||||
- Badges with dynamic text: use width="fit_content" so the badge auto-expands to fit its text.
|
||||
|
||||
${DESIGN_EXAMPLES}
|
||||
|
||||
STRUCTURE:
|
||||
- Single root frame containing ALL elements as children
|
||||
- Root uses layout: "vertical" with gap and padding
|
||||
- Sections are horizontal/vertical frames nested inside
|
||||
- Max 4 levels of nesting
|
||||
- Use gap and padding for spacing — never manual x/y inside layout containers
|
||||
- If you need absolute decorative blobs, place them in a non-layout wrapper frame ("layout: \"none\"")
|
||||
|
||||
SIZING:
|
||||
- Mobile screens: root frame 375x812
|
||||
- Web layouts: root frame 1200x800
|
||||
- Every child MUST have explicit numeric width and height
|
||||
- Use unique descriptive IDs
|
||||
- All colors as fill arrays: [{ "type": "solid", "color": "#hex" }]
|
||||
|
||||
ICONS & IMAGES:
|
||||
- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons. Width and height MUST match the natural aspect ratio of the SVG path data — do not squeeze non-square logos into square dimensions
|
||||
- Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height
|
||||
- Include icons in buttons, nav items, list items, cards for professional polish
|
||||
- Reference the icon patterns in the examples section for common icons
|
||||
|
||||
VISUAL QUALITY GUARDRAILS:
|
||||
- Keep all interactive content in a safe area (at least 20px left/right padding on mobile)
|
||||
- Decorative blobs should be subtle and must not hide inputs/buttons/text
|
||||
- Avoid oversized decorations outside the root frame (max ~10% bleed allowed)
|
||||
- Do not use emoji in headings or body copy unless the user explicitly asks for it
|
||||
SIZING: Mobile root 375x812. Web root 1200x800 (single screen) or 1200x3000-5000 (landing page).
|
||||
ICONS: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths. Size 16-24px.
|
||||
IMAGES: for app showcase sections, prefer phone mockup placeholders over real screenshots.
|
||||
STYLE: Default to light neutral palette unless user explicitly asks for dark/terminal/cyber. Avoid always reusing black+green.
|
||||
|
||||
DESIGN VARIABLES:
|
||||
- When the user message includes a DOCUMENT VARIABLES section, use "$variableName" references instead of hardcoded values wherever a matching variable exists.
|
||||
- Color variables: use in fill color, stroke color, shadow color. Example: [{ "type": "solid", "color": "$primary" }]
|
||||
- Number variables: use for gap, padding, opacity. Example: "gap": "$spacing-md"
|
||||
- Only reference variables that are listed — do NOT invent new variable names.
|
||||
- If no variables are provided, use hardcoded values as usual.
|
||||
- If DOCUMENT VARIABLES are provided, use "$name" refs instead of hardcoded values.
|
||||
- Only reference listed variables.
|
||||
|
||||
Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.`
|
||||
Design like a professional: hierarchy, contrast, whitespace, consistent palette.`
|
||||
|
||||
export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code.
|
||||
|
||||
|
|
|
|||
113
src/services/ai/ai-runtime-config.ts
Normal file
113
src/services/ai/ai-runtime-config.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
export type ThinkingMode = 'adaptive' | 'disabled' | 'enabled'
|
||||
export type ThinkingEffort = 'low' | 'medium' | 'high' | 'max'
|
||||
|
||||
export const DEFAULT_THINKING_MODE: ThinkingMode = 'adaptive'
|
||||
export const DEFAULT_THINKING_EFFORT: ThinkingEffort = 'low'
|
||||
|
||||
export const DEFAULT_THINKING_CONFIG = {
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
} as const
|
||||
|
||||
export const CHAT_STREAM_THINKING_CONFIG = {
|
||||
...DEFAULT_THINKING_CONFIG,
|
||||
} as const
|
||||
|
||||
export const STREAM_TIMEOUT_MIN_MS = 10_000
|
||||
export const DEFAULT_STREAM_HARD_TIMEOUT_MS = 600_000
|
||||
export const DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_GENERATE_TIMEOUT_MS = 180_000
|
||||
|
||||
export const PROMPT_OPTIMIZER_LIMITS = {
|
||||
longPromptCharThreshold: 2200,
|
||||
maxPromptCharsForOrchestrator: 2400,
|
||||
maxPromptCharsForSubAgent: 3600,
|
||||
maxFeatureLines: 12,
|
||||
maxSectionLines: 14,
|
||||
maxFallbackSections: 8,
|
||||
} as const
|
||||
|
||||
export const SUB_AGENT_TIMEOUT_BASE = {
|
||||
hardTimeoutMs: 420_000,
|
||||
noTextTimeoutMs: 210_000,
|
||||
thinkingResetsTimeout: true,
|
||||
} as const
|
||||
|
||||
export const PROMPT_TIMEOUT_BUCKETS = {
|
||||
mediumPromptMaxChars: 4200,
|
||||
} as const
|
||||
|
||||
export const SUB_AGENT_TIMEOUT_PROFILES = {
|
||||
short: {
|
||||
...SUB_AGENT_TIMEOUT_BASE,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 420_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
medium: {
|
||||
...SUB_AGENT_TIMEOUT_BASE,
|
||||
hardTimeoutMs: 600_000,
|
||||
noTextTimeoutMs: 300_000,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 600_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
long: {
|
||||
...SUB_AGENT_TIMEOUT_BASE,
|
||||
hardTimeoutMs: 900_000,
|
||||
noTextTimeoutMs: 480_000,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 900_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const ORCHESTRATOR_TIMEOUT_PROFILES = {
|
||||
short: {
|
||||
hardTimeoutMs: 300_000,
|
||||
noTextTimeoutMs: 150_000,
|
||||
thinkingResetsTimeout: true,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 300_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
medium: {
|
||||
hardTimeoutMs: 420_000,
|
||||
noTextTimeoutMs: 210_000,
|
||||
thinkingResetsTimeout: true,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 420_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
long: {
|
||||
hardTimeoutMs: 600_000,
|
||||
noTextTimeoutMs: 300_000,
|
||||
thinkingResetsTimeout: true,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 600_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
},
|
||||
} as const
|
||||
|
||||
export const DESIGN_STREAM_TIMEOUTS = {
|
||||
hardTimeoutMs: 900_000,
|
||||
noTextTimeoutMs: 480_000,
|
||||
thinkingResetsTimeout: true,
|
||||
pingResetsTimeout: false,
|
||||
firstTextTimeoutMs: 900_000,
|
||||
thinkingMode: DEFAULT_THINKING_MODE,
|
||||
effort: DEFAULT_THINKING_EFFORT,
|
||||
} as const
|
||||
|
||||
export const RETRY_TIMEOUT_CONFIG = {
|
||||
multiplier: 2,
|
||||
hardTimeoutMaxMs: 1_200_000,
|
||||
noTextTimeoutMaxMs: 480_000,
|
||||
firstTextTimeoutMaxMs: 1_200_000,
|
||||
} as const
|
||||
|
|
@ -1,12 +1,43 @@
|
|||
import type { AIStreamChunk } from './ai-types'
|
||||
import type { AIModelInfo } from '@/stores/ai-store'
|
||||
|
||||
const DEFAULT_STREAM_HARD_TIMEOUT_MS = 180_000
|
||||
const DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS = 75_000
|
||||
import {
|
||||
DEFAULT_GENERATE_TIMEOUT_MS,
|
||||
DEFAULT_STREAM_HARD_TIMEOUT_MS,
|
||||
DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS,
|
||||
STREAM_TIMEOUT_MIN_MS,
|
||||
} from './ai-runtime-config'
|
||||
|
||||
interface StreamChatOptions {
|
||||
hardTimeoutMs?: number
|
||||
noTextTimeoutMs?: number
|
||||
/**
|
||||
* Whether thinking events should reset the no-text timeout.
|
||||
* Default: true (backward compatible). Set to false for fast calls
|
||||
* where thinking should NOT prevent the no-text timeout from firing.
|
||||
*/
|
||||
thinkingResetsTimeout?: boolean
|
||||
/**
|
||||
* Whether keep-alive ping events reset the no-text timeout.
|
||||
* Default: true (backward compatible). Set to false to avoid endless
|
||||
* waiting when the server only emits pings.
|
||||
*/
|
||||
pingResetsTimeout?: boolean
|
||||
/**
|
||||
* Max time to wait for the first non-empty text token.
|
||||
* This timeout is independent from keep-alive pings/thinking chunks.
|
||||
*/
|
||||
firstTextTimeoutMs?: number
|
||||
/**
|
||||
* Controls provider thinking mode.
|
||||
* - adaptive: model decides thinking depth
|
||||
* - disabled: disable extended thinking for faster first text
|
||||
* - enabled: explicitly enable extended thinking
|
||||
*/
|
||||
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
|
||||
/** Thinking budget (used when thinkingMode === 'enabled'). */
|
||||
thinkingBudgetTokens?: number
|
||||
/** Model effort level (low is usually faster). */
|
||||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -18,14 +49,21 @@ export async function* streamChat(
|
|||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
model?: string,
|
||||
options?: StreamChatOptions,
|
||||
provider?: string,
|
||||
): AsyncGenerator<AIStreamChunk> {
|
||||
const hardTimeoutMs = Math.max(10_000, options?.hardTimeoutMs ?? DEFAULT_STREAM_HARD_TIMEOUT_MS)
|
||||
const noTextTimeoutMs = Math.max(10_000, options?.noTextTimeoutMs ?? DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS)
|
||||
const hardTimeoutMs = Math.max(STREAM_TIMEOUT_MIN_MS, options?.hardTimeoutMs ?? DEFAULT_STREAM_HARD_TIMEOUT_MS)
|
||||
const noTextTimeoutMs = Math.max(STREAM_TIMEOUT_MIN_MS, options?.noTextTimeoutMs ?? DEFAULT_STREAM_NO_TEXT_TIMEOUT_MS)
|
||||
const thinkingResetsTimeout = options?.thinkingResetsTimeout ?? true
|
||||
const pingResetsTimeout = options?.pingResetsTimeout ?? true
|
||||
const firstTextTimeoutMs = options?.firstTextTimeoutMs
|
||||
? Math.max(STREAM_TIMEOUT_MIN_MS, options.firstTextTimeoutMs)
|
||||
: null
|
||||
|
||||
const controller = new AbortController()
|
||||
let abortReason: 'hard_timeout' | 'no_text_timeout' | null = null
|
||||
let hasNonEmptyText = false
|
||||
let abortReason: 'hard_timeout' | 'no_text_timeout' | 'first_text_timeout' | null = null
|
||||
let noTextTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let firstTextTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let sawText = false
|
||||
|
||||
const clearNoTextTimeout = () => {
|
||||
if (noTextTimeout) {
|
||||
|
|
@ -34,23 +72,49 @@ export async function* streamChat(
|
|||
}
|
||||
}
|
||||
|
||||
const clearFirstTextTimeout = () => {
|
||||
if (firstTextTimeout) {
|
||||
clearTimeout(firstTextTimeout)
|
||||
firstTextTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetActivityTimeout = () => {
|
||||
clearNoTextTimeout()
|
||||
noTextTimeout = setTimeout(() => {
|
||||
abortReason = 'no_text_timeout'
|
||||
controller.abort()
|
||||
}, noTextTimeoutMs)
|
||||
}
|
||||
|
||||
const hardTimeout = setTimeout(() => {
|
||||
abortReason = 'hard_timeout'
|
||||
controller.abort()
|
||||
}, hardTimeoutMs)
|
||||
|
||||
noTextTimeout = setTimeout(() => {
|
||||
if (!hasNonEmptyText) {
|
||||
abortReason = 'no_text_timeout'
|
||||
if (firstTextTimeoutMs) {
|
||||
firstTextTimeout = setTimeout(() => {
|
||||
if (sawText) return
|
||||
abortReason = 'first_text_timeout'
|
||||
controller.abort()
|
||||
}
|
||||
}, noTextTimeoutMs)
|
||||
}, firstTextTimeoutMs)
|
||||
}
|
||||
|
||||
resetActivityTimeout()
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: systemPrompt, messages, model }),
|
||||
body: JSON.stringify({
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
model,
|
||||
provider,
|
||||
thinkingMode: options?.thinkingMode,
|
||||
thinkingBudgetTokens: options?.thinkingBudgetTokens,
|
||||
effort: options?.effort,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
|
|
@ -59,6 +123,7 @@ export async function* streamChat(
|
|||
yield { type: 'error', content: `Server error: ${response.status} ${errBody}` }
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +132,7 @@ export async function* streamChat(
|
|||
yield { type: 'error', content: 'No response stream available' }
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -92,6 +158,7 @@ export async function* streamChat(
|
|||
if (chunk.type === 'done') {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
|
|
@ -100,15 +167,11 @@ export async function* streamChat(
|
|||
return
|
||||
}
|
||||
|
||||
// Keep-alive pings from server — reset timeout but don't yield
|
||||
// Keep-alive pings from server — reset activity timeout but don't yield
|
||||
if (chunk.type === 'ping') {
|
||||
clearNoTextTimeout()
|
||||
noTextTimeout = setTimeout(() => {
|
||||
if (!hasNonEmptyText) {
|
||||
abortReason = 'no_text_timeout'
|
||||
controller.abort()
|
||||
}
|
||||
}, noTextTimeoutMs)
|
||||
if (pingResetsTimeout) {
|
||||
resetActivityTimeout()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -116,16 +179,21 @@ export async function* streamChat(
|
|||
continue
|
||||
}
|
||||
|
||||
// Any non-empty content (text or thinking) counts as activity
|
||||
if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) {
|
||||
hasNonEmptyText = true
|
||||
clearNoTextTimeout()
|
||||
// Any non-empty text counts as activity; thinking only resets
|
||||
// the timeout when thinkingResetsTimeout is true (default).
|
||||
if (chunk.type === 'text' && chunk.content.trim().length > 0) {
|
||||
sawText = true
|
||||
clearFirstTextTimeout()
|
||||
resetActivityTimeout()
|
||||
} else if (chunk.type === 'thinking' && chunk.content.trim().length > 0 && thinkingResetsTimeout) {
|
||||
resetActivityTimeout()
|
||||
}
|
||||
|
||||
yield chunk
|
||||
if (chunk.type === 'error') {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
try {
|
||||
await reader.cancel()
|
||||
} catch {
|
||||
|
|
@ -149,21 +217,24 @@ export async function* streamChat(
|
|||
if (chunk.type === 'done') {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
return
|
||||
}
|
||||
if (chunk.type === 'thinking' && !chunk.content) {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
return
|
||||
}
|
||||
if ((chunk.type === 'text' || chunk.type === 'thinking') && chunk.content.trim().length > 0) {
|
||||
hasNonEmptyText = true
|
||||
clearNoTextTimeout()
|
||||
if (chunk.type === 'text' && chunk.content.trim().length > 0) {
|
||||
sawText = true
|
||||
clearFirstTextTimeout()
|
||||
}
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
yield chunk
|
||||
if (chunk.type === 'error') {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -183,6 +254,11 @@ export async function* streamChat(
|
|||
type: 'error',
|
||||
content: 'AI request timed out. Please retry.',
|
||||
}
|
||||
} else if (abortReason === 'first_text_timeout') {
|
||||
yield {
|
||||
type: 'error',
|
||||
content: 'AI spent too long thinking without producing output. Request stopped, please retry.',
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: 'error',
|
||||
|
|
@ -191,6 +267,7 @@ export async function* streamChat(
|
|||
}
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +277,7 @@ export async function* streamChat(
|
|||
} finally {
|
||||
clearTimeout(hardTimeout)
|
||||
clearNoTextTimeout()
|
||||
clearFirstTextTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,12 +285,11 @@ export async function* streamChat(
|
|||
* Non-streaming completion for design/code generation.
|
||||
* Calls the server-side endpoint which reads ANTHROPIC_API_KEY from env.
|
||||
*/
|
||||
const DEFAULT_GENERATE_TIMEOUT_MS = 180_000
|
||||
|
||||
export async function generateCompletion(
|
||||
systemPrompt: string,
|
||||
userMessage: string,
|
||||
model?: string,
|
||||
provider?: string,
|
||||
): Promise<string> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), DEFAULT_GENERATE_TIMEOUT_MS)
|
||||
|
|
@ -222,7 +299,7 @@ export async function generateCompletion(
|
|||
response = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ system: systemPrompt, message: userMessage, model }),
|
||||
body: JSON.stringify({ system: systemPrompt, message: userMessage, model, provider }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -27,3 +27,74 @@ export interface AIStreamChunk {
|
|||
type: 'text' | 'thinking' | 'done' | 'error' | 'ping'
|
||||
content: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator types — used for parallel sub-agent design generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A sub-task produced by the orchestrator (lightweight — only spatial info) */
|
||||
export interface SubTask {
|
||||
/** Unique ID for this sub-task, e.g. "sidebar", "header" */
|
||||
id: string
|
||||
/** Human-readable label for progress UI */
|
||||
label: string
|
||||
/** Spatial region allocated on the canvas */
|
||||
region: { width: number; height: number }
|
||||
/** ID prefix for all nodes generated by this sub-agent (assigned at runtime) */
|
||||
idPrefix: string
|
||||
/** Parent frame ID to insert into (assigned at runtime) */
|
||||
parentFrameId: string | null
|
||||
}
|
||||
|
||||
/** Style guide produced by the orchestrator for visual consistency */
|
||||
export interface StyleGuide {
|
||||
palette: {
|
||||
background: string
|
||||
surface: string
|
||||
text: string
|
||||
secondary: string
|
||||
accent: string
|
||||
accent2: string
|
||||
border: string
|
||||
}
|
||||
fonts: {
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
aesthetic: string
|
||||
}
|
||||
|
||||
/** Plan produced by the orchestrator (lightweight — only structure) */
|
||||
export interface OrchestratorPlan {
|
||||
rootFrame: {
|
||||
id: string
|
||||
name: string
|
||||
width: number
|
||||
height: number
|
||||
layout?: 'none' | 'vertical' | 'horizontal'
|
||||
gap?: number
|
||||
fill?: Array<{ type: string; color: string }>
|
||||
}
|
||||
styleGuide?: StyleGuide
|
||||
subtasks: SubTask[]
|
||||
}
|
||||
|
||||
/** Progress state for the orchestrated generation */
|
||||
export interface OrchestrationProgress {
|
||||
phase: 'planning' | 'generating' | 'merging' | 'done' | 'error'
|
||||
subtasks: Array<{
|
||||
id: string
|
||||
label: string
|
||||
status: 'pending' | 'streaming' | 'done' | 'error'
|
||||
nodeCount: number
|
||||
}>
|
||||
totalNodes: number
|
||||
}
|
||||
|
||||
/** Result from a single sub-agent */
|
||||
export interface SubAgentResult {
|
||||
subtaskId: string
|
||||
nodes: import('@/types/pen').PenNode[]
|
||||
rawResponse: string
|
||||
error?: string
|
||||
}
|
||||
|
|
|
|||
56
src/services/ai/context-optimizer.ts
Normal file
56
src/services/ai/context-optimizer.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Context optimization utilities for AI chat.
|
||||
* Prevents unbounded growth of chat history and context size.
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10
|
||||
const DEFAULT_MAX_CHARS = 32_000
|
||||
|
||||
/**
|
||||
* Sliding window for chat history.
|
||||
* Keeps the most recent messages while respecting character limits.
|
||||
* Always preserves the first user message for context continuity.
|
||||
*/
|
||||
export function trimChatHistory<T extends { role: string; content: string }>(
|
||||
messages: T[],
|
||||
maxMessages: number = DEFAULT_MAX_MESSAGES,
|
||||
maxChars: number = DEFAULT_MAX_CHARS,
|
||||
): T[] {
|
||||
if (messages.length <= maxMessages) {
|
||||
const totalChars = messages.reduce((sum, m) => sum + m.content.length, 0)
|
||||
if (totalChars <= maxChars) return messages
|
||||
}
|
||||
|
||||
// Always keep the first user message for context continuity
|
||||
const firstUser = messages.find((m) => m.role === 'user')
|
||||
const recentMessages = messages.slice(-maxMessages)
|
||||
|
||||
const window: T[] = []
|
||||
let charCount = 0
|
||||
|
||||
// Add first user message if it's not already in the recent window
|
||||
if (firstUser && !recentMessages.includes(firstUser)) {
|
||||
window.push(firstUser)
|
||||
charCount += firstUser.content.length
|
||||
}
|
||||
|
||||
// Add recent messages, respecting char limit
|
||||
for (const msg of recentMessages) {
|
||||
const msgChars = msg.content.length
|
||||
if (charCount + msgChars > maxChars) {
|
||||
// Truncate this message to fit
|
||||
const remaining = maxChars - charCount
|
||||
if (remaining > 200) {
|
||||
window.push({
|
||||
...msg,
|
||||
content: msg.content.slice(0, remaining) + '\n[...truncated...]',
|
||||
} as T)
|
||||
}
|
||||
break
|
||||
}
|
||||
window.push(msg)
|
||||
charCount += msgChars
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
409
src/services/ai/orchestrator-prompt-optimizer.ts
Normal file
409
src/services/ai/orchestrator-prompt-optimizer.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import type { OrchestratorPlan } from './ai-types'
|
||||
import {
|
||||
ORCHESTRATOR_TIMEOUT_PROFILES,
|
||||
PROMPT_TIMEOUT_BUCKETS,
|
||||
PROMPT_OPTIMIZER_LIMITS,
|
||||
SUB_AGENT_TIMEOUT_PROFILES,
|
||||
} from './ai-runtime-config'
|
||||
|
||||
export interface PreparedDesignPrompt {
|
||||
original: string
|
||||
orchestratorPrompt: string
|
||||
subAgentPrompt: string
|
||||
wasCompressed: boolean
|
||||
originalLength: number
|
||||
}
|
||||
|
||||
export function getSubAgentTimeouts(promptLength: number): {
|
||||
hardTimeoutMs: number
|
||||
noTextTimeoutMs: number
|
||||
thinkingResetsTimeout: boolean
|
||||
pingResetsTimeout: boolean
|
||||
firstTextTimeoutMs: number
|
||||
thinkingMode: 'adaptive' | 'disabled' | 'enabled'
|
||||
effort: 'low' | 'medium' | 'high' | 'max'
|
||||
} {
|
||||
if (promptLength < PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
|
||||
return { ...SUB_AGENT_TIMEOUT_PROFILES.short }
|
||||
}
|
||||
if (promptLength < PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars) {
|
||||
return { ...SUB_AGENT_TIMEOUT_PROFILES.medium }
|
||||
}
|
||||
return { ...SUB_AGENT_TIMEOUT_PROFILES.long }
|
||||
}
|
||||
|
||||
export function getOrchestratorTimeouts(promptLength: number): {
|
||||
hardTimeoutMs: number
|
||||
noTextTimeoutMs: number
|
||||
thinkingResetsTimeout: boolean
|
||||
pingResetsTimeout: boolean
|
||||
firstTextTimeoutMs: number
|
||||
thinkingMode: 'adaptive' | 'disabled' | 'enabled'
|
||||
effort: 'low' | 'medium' | 'high' | 'max'
|
||||
} {
|
||||
if (promptLength < PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
|
||||
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.short }
|
||||
}
|
||||
if (promptLength < PROMPT_TIMEOUT_BUCKETS.mediumPromptMaxChars) {
|
||||
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.medium }
|
||||
}
|
||||
return { ...ORCHESTRATOR_TIMEOUT_PROFILES.long }
|
||||
}
|
||||
|
||||
export function prepareDesignPrompt(prompt: string): PreparedDesignPrompt {
|
||||
const normalized = normalizePromptText(prompt)
|
||||
if (normalized.length <= PROMPT_OPTIMIZER_LIMITS.longPromptCharThreshold) {
|
||||
return {
|
||||
original: prompt,
|
||||
orchestratorPrompt: normalized,
|
||||
subAgentPrompt: normalized,
|
||||
wasCompressed: false,
|
||||
originalLength: normalized.length,
|
||||
}
|
||||
}
|
||||
|
||||
const sections = parseTopLevelSections(prompt)
|
||||
const overview = extractOverviewLine(normalized)
|
||||
const featureLines = extractFeatureHeadings(prompt).slice(0, PROMPT_OPTIMIZER_LIMITS.maxFeatureLines)
|
||||
const sectionLines = extractWebsiteSectionLines(sections).slice(0, PROMPT_OPTIMIZER_LIMITS.maxSectionLines)
|
||||
const highlightLines = extractCoreHighlightLines(sections).slice(0, 6)
|
||||
const needsScreenshotPlaceholder = hasScreenshotRequirements(normalized)
|
||||
|
||||
const sloganLines = extractSloganCandidates(sections).slice(0, 4)
|
||||
const personalityHint = extractProductPersonality(normalized)
|
||||
|
||||
const conciseParts: string[] = [
|
||||
'Design brief (auto-condensed from a long product document):',
|
||||
]
|
||||
|
||||
if (overview) {
|
||||
conciseParts.push(`Product: ${overview}`)
|
||||
}
|
||||
if (personalityHint) {
|
||||
conciseParts.push(`Product personality: ${personalityHint}`)
|
||||
}
|
||||
if (sloganLines.length > 0) {
|
||||
conciseParts.push(`Slogan candidates:\n${sloganLines.join('\n')}`)
|
||||
}
|
||||
if (sectionLines.length > 0) {
|
||||
conciseParts.push(`Preferred landing page sections:\n${sectionLines.join('\n')}`)
|
||||
}
|
||||
if (featureLines.length > 0) {
|
||||
conciseParts.push(`Core capabilities to reflect visually:\n${featureLines.join('\n')}`)
|
||||
}
|
||||
if (highlightLines.length > 0) {
|
||||
conciseParts.push(`Key product highlights:\n${highlightLines.join('\n')}`)
|
||||
}
|
||||
if (needsScreenshotPlaceholder) {
|
||||
conciseParts.push(
|
||||
'Screenshot rule: for sections like "App截图"/"XX截图"/"screenshot", use a minimal phone placeholder (outline + short label), avoid random real screenshots, and do not recreate detailed mini-app internals.',
|
||||
)
|
||||
}
|
||||
conciseParts.push(
|
||||
'Icon rule: never use emoji glyphs as icons; always use path nodes with descriptive icon names (e.g. "SearchIcon", "MenuIcon"). System auto-resolves to verified SVG paths.',
|
||||
)
|
||||
|
||||
conciseParts.push(
|
||||
'Scope guardrails: clear hierarchy, avoid over-detailed micro-content.',
|
||||
)
|
||||
conciseParts.push(
|
||||
'Layout guardrails: keep a stable centered content width.',
|
||||
)
|
||||
|
||||
// Only add landing-page-specific guardrails if the prompt looks like a landing page
|
||||
const isLandingPage = /(?:landing\s*page|website|官网|首页|marketing)/i.test(normalized)
|
||||
if (isLandingPage) {
|
||||
conciseParts.push(
|
||||
'CTA guardrails: avoid inserting extra full-width CTA stripes unless explicitly requested.',
|
||||
)
|
||||
conciseParts.push(
|
||||
'Navbar guardrails: keep logo/links/CTA horizontally aligned; links should be evenly distributed in the center group.',
|
||||
)
|
||||
}
|
||||
|
||||
conciseParts.push(
|
||||
'Typography guardrails: long subtitle/body text should use constrained width so lines wrap naturally.',
|
||||
)
|
||||
conciseParts.push(
|
||||
'Overflow prevention: ALL text inside layout frames must use width="fill_container" (never fixed pixel widths). Buttons/badges with CJK text must be wide enough for character count × fontSize + padding.',
|
||||
)
|
||||
|
||||
const concise = conciseParts.join('\n\n')
|
||||
|
||||
return {
|
||||
original: prompt,
|
||||
orchestratorPrompt: truncateByCharCount(concise, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForOrchestrator),
|
||||
subAgentPrompt: truncateByCharCount(concise, PROMPT_OPTIMIZER_LIMITS.maxPromptCharsForSubAgent),
|
||||
wasCompressed: true,
|
||||
originalLength: normalized.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan {
|
||||
const labels = extractFallbackSectionLabels(prompt)
|
||||
const sectionCount = Math.max(1, labels.length)
|
||||
|
||||
const isMobile = /(?:mobile|手机|phone|app\s*screen|登录|注册|login|signup)/i.test(prompt)
|
||||
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
|
||||
|
||||
const width = isMobile ? 375 : 1200
|
||||
// Mobile app screens: fixed 812px viewport. Desktop landing pages: auto-expand. Desktop app screens: fixed height.
|
||||
const totalHeight = isMobile
|
||||
? 812
|
||||
: isAppScreen
|
||||
? 800
|
||||
: sectionCount >= 4 ? 4000 : 800
|
||||
const heights = allocateSectionHeights(totalHeight, sectionCount)
|
||||
|
||||
return {
|
||||
rootFrame: {
|
||||
id: 'page',
|
||||
name: 'Page',
|
||||
width,
|
||||
height: isMobile ? 812 : (isAppScreen ? totalHeight : 0),
|
||||
layout: 'vertical',
|
||||
fill: [{ type: 'solid', color: '#F8FAFC' }],
|
||||
},
|
||||
subtasks: labels.map((label, index) => ({
|
||||
id: makeSafeSectionId(label, index),
|
||||
label,
|
||||
region: { width, height: heights[index] ?? 120 },
|
||||
idPrefix: '',
|
||||
parentFrameId: null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePromptText(text: string): string {
|
||||
return text
|
||||
.replace(/\r/g, '')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function truncateByCharCount(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text
|
||||
const truncated = text.slice(0, maxChars)
|
||||
const lastBoundary = Math.max(
|
||||
truncated.lastIndexOf('\n'),
|
||||
truncated.lastIndexOf('。'),
|
||||
truncated.lastIndexOf('.'),
|
||||
)
|
||||
if (lastBoundary > Math.floor(maxChars * 0.7)) {
|
||||
return `${truncated.slice(0, lastBoundary).trim()}\n\n[truncated]`
|
||||
}
|
||||
return `${truncated.trim()}\n\n[truncated]`
|
||||
}
|
||||
|
||||
function parseTopLevelSections(markdown: string): Map<string, string> {
|
||||
const lines = markdown.replace(/\r/g, '').split('\n')
|
||||
const sections = new Map<string, string>()
|
||||
let currentTitle: string | null = null
|
||||
let currentLines: string[] = []
|
||||
|
||||
const flush = () => {
|
||||
if (!currentTitle) return
|
||||
const content = currentLines.join('\n').trim()
|
||||
if (content) sections.set(currentTitle, content)
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const headingMatch = line.match(/^##\s+(.+)$/)
|
||||
if (headingMatch) {
|
||||
flush()
|
||||
currentTitle = headingMatch[1].trim()
|
||||
currentLines = []
|
||||
continue
|
||||
}
|
||||
if (currentTitle) {
|
||||
currentLines.push(line)
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
function extractOverviewLine(text: string): string | null {
|
||||
const lines = text.split('\n')
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
if (!line) continue
|
||||
if (line.startsWith('#')) continue
|
||||
if (line.startsWith('|')) continue
|
||||
if (line === '---') continue
|
||||
return line.replace(/\*\*/g, '')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractFeatureHeadings(text: string): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^###\s+\d+\.\s+(.+)$/)?.[1]?.trim())
|
||||
.filter((line): line is string => Boolean(line))
|
||||
.map((line) => `- ${line}`)
|
||||
}
|
||||
|
||||
function extractWebsiteSectionLines(sections: Map<string, string>): string[] {
|
||||
const content = sections.get('推荐的官网结构')
|
||||
if (!content) return []
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^\d+\./.test(line))
|
||||
.map((line) => `- ${line.replace(/^\d+\.\s*/, '')}`)
|
||||
}
|
||||
|
||||
function extractCoreHighlightLines(sections: Map<string, string>): string[] {
|
||||
const content = sections.get('核心亮点')
|
||||
if (!content) return []
|
||||
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith('|') && !line.includes('|------|'))
|
||||
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const columns = line.split('|').map((cell) => cell.trim()).filter(Boolean)
|
||||
if (columns.length >= 2) {
|
||||
result.push(`- ${columns[0]}: ${columns[1]}`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function hasScreenshotRequirements(text: string): boolean {
|
||||
return /(截图|screenshot|mockup)/i.test(text)
|
||||
}
|
||||
|
||||
function extractFallbackSectionLabels(prompt: string): string[] {
|
||||
const lines = prompt.replace(/\r/g, '').split('\n')
|
||||
const labels: string[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
const bulletMatch = line.match(/^- (.+)$/)
|
||||
if (!bulletMatch) continue
|
||||
const cleaned = sanitizePlanSectionLabel(bulletMatch[1])
|
||||
if (!cleaned) continue
|
||||
const key = cleaned.toLowerCase()
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
labels.push(cleaned)
|
||||
if (labels.length >= PROMPT_OPTIMIZER_LIMITS.maxFallbackSections) break
|
||||
}
|
||||
|
||||
if (labels.length > 0) return labels
|
||||
|
||||
// Detect design type to provide appropriate fallback labels
|
||||
const isAppScreen = /(?:login|signup|register|登录|注册|settings|设置|profile|个人|form|表单|dashboard|modal|dialog)/i.test(prompt)
|
||||
if (isAppScreen) {
|
||||
return [
|
||||
'Header',
|
||||
'Main Content',
|
||||
'Actions',
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
'Navigation',
|
||||
'Hero',
|
||||
'Core Highlights',
|
||||
'Feature Showcase',
|
||||
'CTA',
|
||||
'Footer',
|
||||
]
|
||||
}
|
||||
|
||||
function sanitizePlanSectionLabel(label: string): string {
|
||||
const cleaned = label
|
||||
.replace(/^["'`]+|["'`]+$/g, '')
|
||||
.replace(/\s*\([^)]*\)\s*/g, ' ')
|
||||
.replace(/[_*#]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!cleaned) return ''
|
||||
return cleaned.slice(0, 48)
|
||||
}
|
||||
|
||||
function makeSafeSectionId(label: string, index: number): string {
|
||||
const ascii = label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
if (ascii.length > 0) return ascii
|
||||
return `section-${index + 1}`
|
||||
}
|
||||
|
||||
function allocateSectionHeights(totalHeight: number, count: number): number[] {
|
||||
if (count <= 0) return []
|
||||
|
||||
const minHeight = 80
|
||||
const base = Math.max(minHeight, Math.floor(totalHeight / count))
|
||||
const heights = Array.from({ length: count }, () => base)
|
||||
let allocated = base * count
|
||||
|
||||
let idx = 0
|
||||
while (allocated < totalHeight) {
|
||||
heights[idx] += 1
|
||||
allocated += 1
|
||||
idx = (idx + 1) % count
|
||||
}
|
||||
|
||||
idx = count - 1
|
||||
while (allocated > totalHeight) {
|
||||
if (heights[idx] > minHeight) {
|
||||
heights[idx] -= 1
|
||||
allocated -= 1
|
||||
}
|
||||
idx = idx - 1
|
||||
if (idx < 0) idx = count - 1
|
||||
}
|
||||
|
||||
return heights
|
||||
}
|
||||
|
||||
function extractSloganCandidates(sections: Map<string, string>): string[] {
|
||||
const content = sections.get('Slogan 候选') ?? sections.get('slogan') ?? sections.get('标语')
|
||||
if (!content) return []
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^\d+\./.test(line) || line.startsWith('-') || line.startsWith('"'))
|
||||
.map((line) => `- ${line.replace(/^\d+\.\s*/, '').replace(/^-\s*/, '')}`)
|
||||
}
|
||||
|
||||
function extractProductPersonality(text: string): string | null {
|
||||
// Look for personality/tone hints in the document
|
||||
const patterns = [
|
||||
/(?:风格|tone|style|personality)[::]\s*(.+)/i,
|
||||
/(?:品牌调性|brand\s*tone)[::]\s*(.+)/i,
|
||||
/(?:设计风格|design\s*style)[::]\s*(.+)/i,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
if (match) return match[1].trim().slice(0, 100)
|
||||
}
|
||||
|
||||
// Infer from keywords in the text
|
||||
const hasKids = /(?:kids|children|儿童|少儿|幼儿)/i.test(text)
|
||||
const hasTech = /(?:AI|developer|API|tech|code|engineering)/i.test(text)
|
||||
const hasLuxury = /(?:premium|luxury|奢华|高端)/i.test(text)
|
||||
const hasFun = /(?:game|fun|play|趣味|游戏|娱乐)/i.test(text)
|
||||
const hasEducation = /(?:learn|study|education|学习|教育|vocabulary)/i.test(text)
|
||||
|
||||
const traits: string[] = []
|
||||
if (hasKids) traits.push('playful, colorful')
|
||||
if (hasTech) traits.push('modern, technical')
|
||||
if (hasLuxury) traits.push('premium, elegant')
|
||||
if (hasFun) traits.push('energetic, vibrant')
|
||||
if (hasEducation) traits.push('trustworthy, encouraging')
|
||||
|
||||
return traits.length > 0 ? traits.join(', ') : null
|
||||
}
|
||||
153
src/services/ai/orchestrator-prompts.ts
Normal file
153
src/services/ai/orchestrator-prompts.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Orchestrator prompt — ultra-lightweight, only splits into sections.
|
||||
* No design details, no prompt rewriting. Just structure.
|
||||
*/
|
||||
|
||||
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:
|
||||
First determine the design type from the user's request:
|
||||
- "landing page" / "website" / "官网" / "首页" → Landing Page (multi-section scrollable page, 6-10 subtasks)
|
||||
- "login" / "signup" / "register" / "登录" / "注册" → App Screen (single screen, 1-4 subtasks)
|
||||
- "dashboard" / "settings" / "profile" / "设置" / "个人中心" → App Screen (single screen, 2-5 subtasks)
|
||||
- "form" / "表单" / "checkout" / "结算" → App Screen (single screen, 1-4 subtasks)
|
||||
- Other app screens (modal, dialog, onboarding, etc.) → App Screen (1-5 subtasks)
|
||||
|
||||
FORMAT:
|
||||
{"rootFrame":{"id":"page","name":"Page","width":1200,"height":0,"layout":"vertical","fill":[{"type":"solid","color":"#0B1120"}]},"styleGuide":{"palette":{"background":"#0B1120","surface":"#141D33","text":"#F1F5F9","secondary":"#94A3B8","accent":"#3B82F6","accent2":"#06B6D4","border":"#1E293B"},"fonts":{"heading":"Space Grotesk","body":"Inter"},"aesthetic":"dark navy with blue gradient accents"},"subtasks":[{"id":"nav","label":"Navigation Bar","region":{"width":1200,"height":72}},{"id":"hero","label":"Hero Section","region":{"width":1200,"height":560}},{"id":"features","label":"Feature Cards","region":{"width":1200,"height":480}}]}
|
||||
|
||||
RULES:
|
||||
- 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)
|
||||
- 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).
|
||||
- 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.
|
||||
- Choose a visual direction (palette, fonts, aesthetic) that matches the product personality and target audience. Output it in "styleGuide".
|
||||
- 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 height: Mobile (width=375) → set height=812 (fixed viewport). Desktop (width=1200) → set height=0 (auto-expands as sections are generated).
|
||||
- Landing page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, testimonials 300-400px, CTA 200-300px, footer 200-300px.
|
||||
- App screen height hints: status bar 44px, header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px.
|
||||
- If a section is about "App截图"/"XX截图"/"screenshot"/"mockup", plan it as a phone mockup placeholder block, not a detailed mini-app reconstruction.
|
||||
- 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.
|
||||
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
|
||||
- NO explanation. NO markdown. JUST the JSON object.`
|
||||
|
||||
// Safe code block delimiter
|
||||
const BLOCK = "```"
|
||||
|
||||
/**
|
||||
* Sub-agent prompt — lean version of DESIGN_GENERATOR_PROMPT.
|
||||
* Only essential schema + JSONL output format. Includes one example for format clarity.
|
||||
*/
|
||||
export const SUB_AGENT_PROMPT = `PenNode flat JSONL engine. Output a ${BLOCK}json block with ONE node per line.
|
||||
|
||||
TYPES: frame (width,height,layout,gap,padding,justifyContent,alignItems,clipContent,cornerRadius,fill,stroke,effects,children), rectangle, ellipse, text (content,fontFamily,fontSize,fontWeight,fontStyle,fill,width,height,textAlign,textGrowth,lineHeight,letterSpacing,textAlignVertical), path (d,width,height,fill,stroke), image (src,width,height)
|
||||
textGrowth: "auto" (expand horizontally, no wrap), "fixed-width" (fixed width, height auto-sizes to wrapped content), "fixed-width-height" (both fixed). Default for text in layouts: "fixed-width" + width="fill_container".
|
||||
lineHeight: multiplier (1.1-1.2 for headings, 1.4-1.6 for body). letterSpacing: px (-0.5 for headlines, 0.5-2 for uppercase labels). textAlignVertical: "top"|"middle"|"bottom".
|
||||
SHARED: id, type, name, x, y, opacity
|
||||
width/height: number (px), "fill_container" (stretch), "fit_content" (shrink-wrap)
|
||||
In vertical layout: "fill_container" width = stretch horizontally; height = fill remaining vertical space.
|
||||
In horizontal layout: "fill_container" width = fill remaining horizontal space; height = stretch vertically.
|
||||
padding: number or [vertical,horizontal] (e.g. [0,80]) or [top,right,bottom,left]
|
||||
clipContent: boolean — clips overflowing children. ALWAYS use on frames with cornerRadius + image children.
|
||||
justifyContent: "start"|"center"|"end"|"space_between"|"space_around". Use "space_between" for navbars/footers.
|
||||
Fill=[{"type":"solid","color":"#hex"}] or [{"type":"linear_gradient","angle":N,"stops":[{"offset":0,"color":"#hex"},{"offset":1,"color":"#hex"}]}]
|
||||
Stroke={"thickness":N,"fill":[...]}
|
||||
cornerRadius=number. fill=array.
|
||||
|
||||
CRITICAL LAYOUT RULES (violations cause rendering bugs):
|
||||
- Section root frame: width="fill_container", height="fit_content", layout="vertical". NEVER use fixed pixel height on section root — it causes blank gaps. Let content determine height.
|
||||
- NEVER set x or y on ANY child inside a layout frame. The layout engine positions them automatically.
|
||||
- ALL nodes must be descendants of the section root. No orphan/floating nodes.
|
||||
- CHILD SIZE RULE: every child's width must be ≤ parent's content area. Use "fill_container" when in doubt.
|
||||
- WIDTH CONSISTENCY: siblings in a vertical layout must use the SAME width strategy. If one input/button uses "fill_container", ALL inputs/buttons in that container must also use "fill_container". Mixing fixed-px and fill_container causes misalignment.
|
||||
- NEVER use "fill_container" on children of a "fit_content" parent — this creates a circular dependency and breaks layout.
|
||||
- CLIP CONTENT: set clipContent: true on cards with cornerRadius + image children. Prevents overflow past rounded corners.
|
||||
- FLEX LAYOUT: use justifyContent to distribute children:
|
||||
"space_between" = push first/last to edges, even space between (BEST for navbars: logo | links | CTA).
|
||||
"space_around" = equal space around each child.
|
||||
"center" = center-pack children.
|
||||
- SIZING: width/height accept: number (px), "fill_container" (stretch to parent), "fit_content" (shrink to content).
|
||||
- PADDING: number (uniform), [vertical, horizontal] (e.g. [0, 80] for side padding), or [top, right, bottom, left].
|
||||
- For two-column layouts: horizontal frame with two child frames, each "fill_container" width.
|
||||
- For centered content: frame with alignItems="center", then content frame with fixed width (e.g. 1080).
|
||||
- SHORT TEXT: buttons/labels can omit textGrowth (defaults to "auto" = expands horizontally).
|
||||
|
||||
⚠️ TEXT RULES (the #1 most common bug source — MUST follow):
|
||||
TEXT WIDTH:
|
||||
- ALL text nodes inside a layout frame → width="fill_container" + textGrowth="fixed-width". NO EXCEPTIONS.
|
||||
- NEVER output a text node with a fixed pixel width (width:224, width:378, width:784 etc.) inside a layout frame. This causes the text to overflow horizontally and break the design.
|
||||
- The ONLY time text can have a fixed pixel width is when it is NOT inside a layout frame (layout="none" parent).
|
||||
- BAD example (causes overflow): parent card width=195, padding=[24,40,24,40] (available=115px), child text width=378 → text overflows by 263px!
|
||||
- GOOD example: same parent card, child text width="fill_container" → auto-constrained to 115px, wraps correctly.
|
||||
|
||||
TEXT WRAPPING (textGrowth):
|
||||
- Any text content longer than ~15 characters MUST have textGrowth="fixed-width". Without it, text expands horizontally in a single line and overflows.
|
||||
- textGrowth="fixed-width" makes the text WRAP within its width and auto-size its height. This is required for descriptions, paragraphs, subtitles, and any multi-word text.
|
||||
- ONLY omit textGrowth for very short labels (1-3 words) like button text "Submit", nav links, or badge labels.
|
||||
- BAD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container"} → text renders as ONE long line, overflows!
|
||||
- GOOD: {"type":"text","content":"PolarWords 的 AI 助记系统为每个单词生成专属记忆方案...","fontSize":16,"width":"fill_container","textGrowth":"fixed-width","lineHeight":1.6} → text wraps within parent, height auto-sizes.
|
||||
|
||||
TEXT HEIGHT (the #2 most common bug — causes overlap):
|
||||
- NEVER set explicit pixel height on text nodes (e.g. height:22, height:44). OMIT the height property entirely on text.
|
||||
- The layout engine auto-calculates text height from textGrowth + content. An explicit small height clips the text and causes it to overlap with siblings below.
|
||||
- BAD: {"type":"text","content":"50,000+","fontSize":36,"height":22} → 22px is way too small for 36px font, text overlaps next element!
|
||||
- GOOD: {"type":"text","content":"50,000+","fontSize":36,"width":"fill_container"} → engine auto-sizes height to ~43px, no overlap.
|
||||
- This applies to ALL text nodes: headings, body, labels, numbers, captions. Never set height on text.
|
||||
|
||||
CARD ROW ALIGNMENT:
|
||||
- When cards are siblings in a horizontal layout, ALL cards MUST use height="fill_container". This makes all cards match the tallest card's height, creating a visually aligned row.
|
||||
- BAD: 5 cards in horizontal row, each with different fixed heights → uneven, ugly row.
|
||||
- GOOD: 5 cards in horizontal row, each with height="fill_container" → all same height, clean alignment.
|
||||
- Card content (icon + title + description) should use width="fill_container" on text nodes so text wraps within the card.
|
||||
|
||||
FORMAT: Each line has "_parent" (null=root, else parent-id). Parent before children.
|
||||
${BLOCK}json
|
||||
{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content","layout":"vertical","padding":80,"gap":24,"alignItems":"center","fill":[{"type":"solid","color":"#0B1120"}]}
|
||||
{"_parent":"root","id":"content","type":"frame","name":"Content","width":1080,"height":400,"layout":"horizontal","gap":48,"alignItems":"center"}
|
||||
{"_parent":"content","id":"left","type":"frame","name":"Text Column","width":520,"height":360,"layout":"vertical","gap":20}
|
||||
{"_parent":"left","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48,"fontWeight":700,"fontFamily":"Space Grotesk","lineHeight":1.1,"letterSpacing":-0.5,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#F1F5F9"}]}
|
||||
{"_parent":"left","id":"desc","type":"text","name":"Description","content":"AI-powered vocabulary learning","fontSize":18,"lineHeight":1.5,"textGrowth":"fixed-width","width":"fill_container","fill":[{"type":"solid","color":"#94A3B8"}]}
|
||||
{"_parent":"left","id":"cta","type":"frame","name":"CTA Button","width":180,"height":48,"cornerRadius":10,"layout":"horizontal","gap":8,"justifyContent":"center","alignItems":"center","fill":[{"type":"solid","color":"#3B82F6"}]}
|
||||
{"_parent":"cta","id":"cta-text","type":"text","name":"CTA Label","content":"Get Started","fontSize":16,"fontWeight":600,"width":"fill_container","fill":[{"type":"solid","color":"#FFFFFF"}]}
|
||||
{"_parent":"content","id":"phone","type":"frame","name":"Phone Mockup","width":280,"height":560,"cornerRadius":32,"fill":[{"type":"solid","color":"#141D33"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#1E293B"}]}}
|
||||
${BLOCK}
|
||||
|
||||
DESIGN RULES:
|
||||
- Typography: Display 40-56px → Heading 28-36px → Subheading 20-24px → Body 16-18px → Caption 13-14px. Always set lineHeight: headings 1.1-1.2, body 1.4-1.6, captions 1.3. Use letterSpacing: -0.5 for large headlines, 0.5-2 for uppercase labels.
|
||||
- CJK FONTS: When content is in Chinese/Japanese/Korean, use CJK-compatible fonts — "Noto Sans SC" for headings, "Inter" or "Noto Sans SC" for body. NEVER use "Space Grotesk" or "Manrope" for CJK text (they have no CJK glyphs). CJK lineHeight: 1.3-1.4 for headings, 1.6-1.8 for body. CJK letterSpacing: 0 for body, never negative.
|
||||
- Cards in horizontal rows: ALL cards MUST use width="fill_container" + height="fill_container" for even distribution and equal height. Never use fixed pixel width/height on cards in a row. A card with icon+title+description needs at least 160-200px content height — the row will auto-size. ALWAYS set clipContent: true on cards with cornerRadius + image children.
|
||||
- Icons: "path" nodes with descriptive names (e.g. "SearchIcon", "MenuIcon", "ArrowRightIcon", "StarIcon", "ShieldIcon", "ZapIcon"). System auto-resolves to verified SVG paths. Size 16-24px. NEVER use emoji.
|
||||
- PHONE MOCKUP: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse or circle for mockups. NEVER add any children inside (no text, no frames, no images). Every phone mockup must look identical.
|
||||
- NEVER use ellipse nodes for decorative shapes. Use frame or rectangle with cornerRadius instead.
|
||||
- Use STYLE GUIDE colors/fonts from user prompt consistently. Do not introduce random colors.
|
||||
- TEXT: text inside layout frames MUST use textGrowth="fixed-width" + width="fill_container". NEVER set fixed pixel width on text. NEVER set height on text — omit it entirely, the engine auto-sizes. Short labels/buttons can omit textGrowth.
|
||||
- BUTTONS: height 44-52px, padding [12, 24] minimum. With icon+text: layout="horizontal", gap=8, alignItems="center". Sizing: "fill_container" (stretch), "fit_content" (hug content), or fixed px — choose per context.
|
||||
- CJK BUTTONS (Chinese/Japanese/Korean text): each CJK character renders ~1.0× fontSize wide. For "免费下载" (4 chars) at fontSize 15: content needs ~60px width → button needs 60 + horizontal padding (e.g. padding [8,22] = 44px → total 104px minimum). ALWAYS calculate: button width ≥ charCount × fontSize + totalHorizontalPadding.
|
||||
- ICON-ONLY BUTTONS (heart, bookmark, share, etc.): square frame, minimum 44x44px, justifyContent="center", alignItems="center". Path icon inside 20-24px.
|
||||
- BADGES/TAGS (e.g. "NEW", "SALE", "PRO"): frame with padding [4, 12] minimum, cornerRadius 4-6, height="fit_content". For badges with CJK or long text, use width="fit_content" so badge auto-sizes. Text inside must NOT be clipped — use short fontSize (11-13px), no textGrowth needed.
|
||||
- BUTTON + ICON-BUTTON ROW: horizontal frame, gap=8-12. Primary button width="fill_container" to take remaining space; icon-only button fixed square (44-48px).
|
||||
- LANDING PAGE SECTIONS (only when designing landing pages/websites):
|
||||
- Hero: large headline (40-56px), gradient or bold backgrounds, clear CTA, generous whitespace
|
||||
- Visual rhythm: alternate section backgrounds for separation
|
||||
- CTAs: bold accent color, padding [16, 32] minimum
|
||||
- Nav bar: use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button). padding=[0,80]. This auto-distributes them perfectly.
|
||||
- APP SCREENS (login, settings, forms, dashboards, etc.):
|
||||
- Focus on the screen's core functionality, avoid unnecessary decorative sections.
|
||||
- Form inputs: consistent height (48-56px), clear labels, proper spacing (16-24px gap). Use width="fill_container" for inputs so they align with parent width.
|
||||
- Primary/submit buttons: use width="fill_container" to match the form width.
|
||||
- Button rows (social login etc.): wrap in horizontal frame with width="fill_container", gap=12. Each button uses width="fit_content" (hug content) so they stay compact. Use justifyContent="center" or "space_between" to distribute.
|
||||
- Fixed-width children must NOT exceed their parent's content area (parent width minus padding).
|
||||
|
||||
SELF-CHECK before finishing (mentally verify these):
|
||||
1. Every text node inside a layout frame has width="fill_container" + textGrowth="fixed-width"? (not a fixed pixel width, not missing textGrowth)
|
||||
2. Every text with content > 15 chars has textGrowth="fixed-width"? (without it, text won't wrap and will overflow)
|
||||
3. NO text node has an explicit pixel height (height:22, height:44 etc.)? Text height must be OMITTED — engine auto-sizes it.
|
||||
4. Cards in horizontal rows all use width="fill_container" + height="fill_container"? (ensures equal distribution and height)
|
||||
5. Every button/badge with CJK text has enough width for its characters + padding?
|
||||
6. No child has a fixed pixel width exceeding its parent's available content area?
|
||||
7. If content is CJK: using "Noto Sans SC" (not "Space Grotesk") for headings, and lineHeight ≥ 1.3 for headings, ≥ 1.6 for body?
|
||||
|
||||
Start with ${BLOCK}json immediately. No preamble, no <step> tags.`
|
||||
888
src/services/ai/orchestrator.ts
Normal file
888
src/services/ai/orchestrator.ts
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
/**
|
||||
* Orchestrator for parallel design generation.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Fast "architect" API call decomposes the prompt into spatial sub-tasks
|
||||
* 2. Root frame is created on canvas
|
||||
* 3. Multiple sub-agents execute in parallel, each streaming JSONL
|
||||
* 4. Nodes are inserted to canvas in real-time with animation
|
||||
*
|
||||
* Falls back to single-call generation on any orchestrator failure.
|
||||
*/
|
||||
|
||||
import type { PenNode, FrameNode } from '@/types/pen'
|
||||
import type { VariableDefinition } from '@/types/variables'
|
||||
import type {
|
||||
AIDesignRequest,
|
||||
OrchestratorPlan,
|
||||
OrchestrationProgress,
|
||||
SubTask,
|
||||
SubAgentResult,
|
||||
} from './ai-types'
|
||||
import { streamChat } from './ai-service'
|
||||
import { ORCHESTRATOR_PROMPT, SUB_AGENT_PROMPT } from './orchestrator-prompts'
|
||||
import {
|
||||
type PreparedDesignPrompt,
|
||||
buildFallbackPlanFromPrompt,
|
||||
getOrchestratorTimeouts,
|
||||
getSubAgentTimeouts,
|
||||
prepareDesignPrompt,
|
||||
} from './orchestrator-prompt-optimizer'
|
||||
import {
|
||||
adjustRootFrameHeightToContent,
|
||||
expandRootFrameHeight,
|
||||
extractStreamingNodes,
|
||||
extractJsonFromResponse,
|
||||
insertStreamingNode,
|
||||
buildVariableContext,
|
||||
resetGenerationRemapping,
|
||||
setGenerationContextHint,
|
||||
setGenerationCanvasWidth,
|
||||
applyPostStreamingTreeHeuristics,
|
||||
} from './design-generator'
|
||||
import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import { zoomToFitContent } from '@/canvas/use-fabric-canvas'
|
||||
import {
|
||||
resetAnimationState,
|
||||
startNewAnimationBatch,
|
||||
} from './design-animation'
|
||||
import { RETRY_TIMEOUT_CONFIG } from './ai-runtime-config'
|
||||
|
||||
interface StreamTimeoutConfig {
|
||||
hardTimeoutMs: number
|
||||
noTextTimeoutMs: number
|
||||
thinkingResetsTimeout: boolean
|
||||
pingResetsTimeout?: boolean
|
||||
firstTextTimeoutMs?: number
|
||||
thinkingMode?: 'adaptive' | 'disabled' | 'enabled'
|
||||
thinkingBudgetTokens?: number
|
||||
effort?: 'low' | 'medium' | 'high' | 'max'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeOrchestration(
|
||||
request: AIDesignRequest,
|
||||
callbacks?: {
|
||||
onApplyPartial?: (count: number) => void
|
||||
onTextUpdate?: (text: string) => void
|
||||
animated?: boolean
|
||||
},
|
||||
): Promise<{ nodes: PenNode[]; rawResponse: string }> {
|
||||
setGenerationContextHint(request.prompt)
|
||||
const animated = callbacks?.animated ?? false
|
||||
const preparedPrompt = prepareDesignPrompt(request.prompt)
|
||||
|
||||
const renderPlanningStatus = (message: string) => {
|
||||
callbacks?.onTextUpdate?.(
|
||||
`<step title="Planning layout" status="streaming">${message}</step>`,
|
||||
)
|
||||
}
|
||||
|
||||
if (preparedPrompt.wasCompressed) {
|
||||
console.log(
|
||||
'[Orchestrator] Compressed long prompt:',
|
||||
`${preparedPrompt.originalLength} -> ${preparedPrompt.subAgentPrompt.length} chars`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// -- Phase 1: Planning (streaming) --
|
||||
renderPlanningStatus('Analyzing design structure...')
|
||||
|
||||
let plan: OrchestratorPlan
|
||||
try {
|
||||
plan = await callOrchestrator(
|
||||
preparedPrompt.orchestratorPrompt,
|
||||
preparedPrompt.originalLength,
|
||||
(thinking) => {
|
||||
const truncated = thinking.length > 200
|
||||
? thinking.slice(-200) + '...'
|
||||
: thinking
|
||||
renderPlanningStatus(truncated)
|
||||
},
|
||||
)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown orchestrator error'
|
||||
console.warn('[Orchestrator] Planner failed, using fallback plan:', message)
|
||||
renderPlanningStatus('Planner timeout, switching to fallback layout plan...')
|
||||
plan = buildFallbackPlanFromPrompt(preparedPrompt.orchestratorPrompt)
|
||||
}
|
||||
|
||||
// Assign ID prefixes
|
||||
for (const st of plan.subtasks) {
|
||||
st.idPrefix = st.id
|
||||
st.parentFrameId = plan.rootFrame.id
|
||||
}
|
||||
|
||||
// Set canvas width hint for accurate text height estimation
|
||||
setGenerationCanvasWidth(plan.rootFrame.width)
|
||||
|
||||
// Show planning done + all subtask steps as pending
|
||||
emitProgress(plan, {
|
||||
phase: 'generating',
|
||||
subtasks: plan.subtasks.map((st) => ({
|
||||
id: st.id, label: st.label, status: 'pending' as const, nodeCount: 0,
|
||||
})),
|
||||
totalNodes: 0,
|
||||
}, callbacks)
|
||||
|
||||
// -- Phase 2: Setup canvas --
|
||||
resetGenerationRemapping()
|
||||
|
||||
if (animated) {
|
||||
resetAnimationState()
|
||||
useHistoryStore.getState().startBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
|
||||
// Mobile screens have fixed dimensions (375x812), use plan height directly.
|
||||
// Desktop: pre-allocate total planned height so the root frame's background
|
||||
// covers the entire page from the start. Without this, children streaming into
|
||||
// later sections appear outside the root's visible background area.
|
||||
const isMobile = plan.rootFrame.width <= 480
|
||||
const totalPlannedHeight = plan.subtasks.reduce((sum, st) => sum + st.region.height, 0)
|
||||
const initialHeight = isMobile
|
||||
? (plan.rootFrame.height || 812)
|
||||
: Math.max(320, totalPlannedHeight)
|
||||
const rootNode: FrameNode = {
|
||||
id: plan.rootFrame.id,
|
||||
type: 'frame',
|
||||
name: plan.rootFrame.name,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: plan.rootFrame.width,
|
||||
height: initialHeight,
|
||||
layout: plan.rootFrame.layout ?? 'vertical',
|
||||
gap: plan.rootFrame.gap ?? 0,
|
||||
fill: (plan.rootFrame.fill as FrameNode['fill']) ?? [
|
||||
{ type: 'solid', color: plan.styleGuide?.palette?.background ?? '#FFFFFF' },
|
||||
],
|
||||
children: [],
|
||||
}
|
||||
|
||||
insertStreamingNode(rootNode, null)
|
||||
if (typeof window !== 'undefined') {
|
||||
// Wait for store -> canvas sync, then fit/center around generated root frame.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => zoomToFitContent())
|
||||
})
|
||||
}
|
||||
|
||||
// -- Phase 3: Parallel sub-agent execution --
|
||||
const progress: OrchestrationProgress = {
|
||||
phase: 'generating',
|
||||
subtasks: plan.subtasks.map((st) => ({
|
||||
id: st.id,
|
||||
label: st.label,
|
||||
status: 'pending' as const,
|
||||
nodeCount: 0,
|
||||
})),
|
||||
totalNodes: 0,
|
||||
}
|
||||
|
||||
let results: SubAgentResult[]
|
||||
try {
|
||||
results = await executeSubAgentsSequentially(
|
||||
plan,
|
||||
request,
|
||||
preparedPrompt,
|
||||
progress,
|
||||
callbacks,
|
||||
)
|
||||
if (animated) {
|
||||
adjustRootFrameHeightToContent()
|
||||
}
|
||||
} finally {
|
||||
if (animated) {
|
||||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Phase 4: Collect results --
|
||||
// Mark all completed subtasks as done in final progress
|
||||
for (const entry of progress.subtasks) {
|
||||
if (entry.status !== 'error') {
|
||||
entry.status = 'done'
|
||||
}
|
||||
}
|
||||
progress.phase = 'done'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
|
||||
const allNodes: PenNode[] = [rootNode]
|
||||
for (const r of results) {
|
||||
allNodes.push(...r.nodes)
|
||||
}
|
||||
|
||||
const generatedNodeCount = allNodes.length - 1
|
||||
if (generatedNodeCount === 0) {
|
||||
throw new Error('Orchestration produced no nodes beyond root frame')
|
||||
}
|
||||
|
||||
if (!animated) {
|
||||
adjustRootFrameHeightToContent()
|
||||
}
|
||||
const adjustedRoot = useDocumentStore.getState().getNodeById(DEFAULT_FRAME_ID)
|
||||
if (adjustedRoot && adjustedRoot.type === 'frame') {
|
||||
rootNode.height = adjustedRoot.height
|
||||
}
|
||||
|
||||
// Build final rawResponse that includes step tags so the chat message
|
||||
// shows the complete pipeline progress after streaming ends
|
||||
const finalStepTags = buildFinalStepTags(plan, progress)
|
||||
|
||||
return { nodes: allNodes, rawResponse: finalStepTags }
|
||||
} finally {
|
||||
setGenerationContextHint('')
|
||||
setGenerationCanvasWidth(1200) // Reset to default
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orchestrator call — fast decomposition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function callOrchestrator(
|
||||
prompt: string,
|
||||
timeoutHintLength: number,
|
||||
onThinking?: (thinking: string) => void,
|
||||
): Promise<OrchestratorPlan> {
|
||||
console.log('[Orchestrator] Calling streamChat...')
|
||||
|
||||
let rawResponse = ''
|
||||
let thinkingContent = ''
|
||||
|
||||
for await (const chunk of streamChat(
|
||||
ORCHESTRATOR_PROMPT,
|
||||
[{ role: 'user', content: prompt }],
|
||||
undefined,
|
||||
getOrchestratorTimeouts(timeoutHintLength),
|
||||
)) {
|
||||
if (chunk.type === 'text') {
|
||||
rawResponse += chunk.content
|
||||
} else if (chunk.type === 'thinking') {
|
||||
thinkingContent += chunk.content
|
||||
onThinking?.(thinkingContent)
|
||||
} else if (chunk.type === 'error') {
|
||||
throw new Error(`Orchestrator failed: ${chunk.content}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Orchestrator] Raw response:', rawResponse.slice(0, 500))
|
||||
|
||||
const plan = parseOrchestratorResponse(rawResponse)
|
||||
if (!plan) {
|
||||
console.error('[Orchestrator] Failed to parse plan from:', rawResponse.slice(0, 500))
|
||||
throw new Error('Failed to parse orchestrator plan')
|
||||
}
|
||||
|
||||
console.log('[Orchestrator] Plan:', plan.subtasks.length, 'subtasks')
|
||||
return plan
|
||||
}
|
||||
|
||||
function parseOrchestratorResponse(raw: string): OrchestratorPlan | null {
|
||||
const trimmed = raw.trim()
|
||||
|
||||
// Try direct parse
|
||||
const plan = tryParsePlan(trimmed)
|
||||
if (plan) return plan
|
||||
|
||||
// Try extracting from code fences
|
||||
const fenceMatch = trimmed.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
|
||||
if (fenceMatch) {
|
||||
const fenced = tryParsePlan(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 = tryParsePlan(trimmed.slice(firstBrace, lastBrace + 1))
|
||||
if (braced) return braced
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function tryParsePlan(text: string): OrchestratorPlan | null {
|
||||
try {
|
||||
const obj = JSON.parse(text) as Record<string, unknown>
|
||||
if (!obj.rootFrame || typeof obj.rootFrame !== 'object') return null
|
||||
if (!Array.isArray(obj.subtasks) || obj.subtasks.length === 0) return null
|
||||
|
||||
const rf = obj.rootFrame as Record<string, unknown>
|
||||
if (!rf.id || !rf.width || (rf.height == null)) return null
|
||||
|
||||
for (const st of obj.subtasks as Record<string, unknown>[]) {
|
||||
if (!st.id || !st.region) return null
|
||||
}
|
||||
|
||||
const plan = obj as unknown as OrchestratorPlan
|
||||
|
||||
// Extract optional styleGuide if present and well-formed
|
||||
if (obj.styleGuide && typeof obj.styleGuide === 'object') {
|
||||
const sg = obj.styleGuide as Record<string, unknown>
|
||||
if (sg.palette && typeof sg.palette === 'object' && sg.fonts && typeof sg.fonts === 'object') {
|
||||
plan.styleGuide = sg as unknown as import('./ai-types').StyleGuide
|
||||
}
|
||||
}
|
||||
|
||||
return plan
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sequential sub-agent execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function executeSubAgentsSequentially(
|
||||
plan: OrchestratorPlan,
|
||||
request: AIDesignRequest,
|
||||
preparedPrompt: PreparedDesignPrompt,
|
||||
progress: OrchestrationProgress,
|
||||
callbacks?: {
|
||||
onApplyPartial?: (count: number) => void
|
||||
onTextUpdate?: (text: string) => void
|
||||
animated?: boolean
|
||||
},
|
||||
): Promise<SubAgentResult[]> {
|
||||
const results: SubAgentResult[] = []
|
||||
const timeoutOptions = getSubAgentTimeouts(preparedPrompt.originalLength)
|
||||
const retryTimeoutOptions = getRetrySubAgentTimeouts(timeoutOptions)
|
||||
|
||||
for (let i = 0; i < plan.subtasks.length; i++) {
|
||||
let result = await executeSubAgent(
|
||||
plan.subtasks[i], plan, request, preparedPrompt, timeoutOptions, progress, i, callbacks,
|
||||
)
|
||||
if (shouldRetrySubtask(result)) {
|
||||
console.warn(
|
||||
`[Orchestrator] Retrying subtask "${plan.subtasks[i].label}" with extended timeout after: ${result.error}`,
|
||||
)
|
||||
result = await executeSubAgent(
|
||||
plan.subtasks[i], plan, request, preparedPrompt, retryTimeoutOptions, progress, i, callbacks,
|
||||
)
|
||||
}
|
||||
|
||||
if (result.nodes.length === 0) {
|
||||
const minimalPrompt = buildMinimalFallbackPrompt(preparedPrompt.subAgentPrompt, plan.subtasks[i].label)
|
||||
console.warn(
|
||||
`[Orchestrator] Retrying subtask "${plan.subtasks[i].label}" in minimal mode because no nodes were produced.`,
|
||||
)
|
||||
result = await executeSubAgent(
|
||||
plan.subtasks[i],
|
||||
plan,
|
||||
request,
|
||||
preparedPrompt,
|
||||
retryTimeoutOptions,
|
||||
progress,
|
||||
i,
|
||||
callbacks,
|
||||
minimalPrompt,
|
||||
)
|
||||
}
|
||||
|
||||
if (result.nodes.length === 0) {
|
||||
const placeholderNodes = insertLocalSubtaskPlaceholder(
|
||||
plan.subtasks[i],
|
||||
plan,
|
||||
progress.subtasks[i],
|
||||
progress,
|
||||
)
|
||||
if (placeholderNodes.length > 0) {
|
||||
callbacks?.onApplyPartial?.(progress.totalNodes)
|
||||
emitProgress(plan, progress, callbacks)
|
||||
result = {
|
||||
...result,
|
||||
nodes: placeholderNodes,
|
||||
error: result.error
|
||||
? `${result.error}; local placeholder inserted`
|
||||
: 'Local placeholder inserted after repeated empty output',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push(result)
|
||||
|
||||
// Progressively expand root frame after each subtask is done (desktop only).
|
||||
// Mobile has a fixed viewport height so no progressive expansion is needed.
|
||||
if (result.nodes.length > 0 && plan.rootFrame.width > 480) {
|
||||
expandRootFrameHeight()
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async function executeSubAgent(
|
||||
subtask: SubTask,
|
||||
plan: OrchestratorPlan,
|
||||
request: AIDesignRequest,
|
||||
preparedPrompt: PreparedDesignPrompt,
|
||||
timeoutOptions: StreamTimeoutConfig,
|
||||
progress: OrchestrationProgress,
|
||||
index: number,
|
||||
callbacks?: {
|
||||
onApplyPartial?: (count: number) => void
|
||||
onTextUpdate?: (text: string) => void
|
||||
animated?: boolean
|
||||
},
|
||||
promptOverride?: string,
|
||||
): Promise<SubAgentResult> {
|
||||
const animated = callbacks?.animated ?? false
|
||||
const progressEntry = progress.subtasks[index]
|
||||
progressEntry.status = 'streaming'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
|
||||
// Update context hint with subtask label so heuristics can detect
|
||||
// screenshot/mockup context even when the user prompt doesn't mention it
|
||||
setGenerationContextHint(`${request.prompt} ${subtask.label}`)
|
||||
|
||||
const userPrompt = buildSubAgentUserPrompt(
|
||||
subtask,
|
||||
plan,
|
||||
promptOverride ?? preparedPrompt.subAgentPrompt,
|
||||
request.context?.variables,
|
||||
request.context?.themes,
|
||||
)
|
||||
|
||||
let rawResponse = ''
|
||||
const nodes: PenNode[] = []
|
||||
let streamOffset = 0
|
||||
let subtaskRootId: string | null = null
|
||||
|
||||
try {
|
||||
for await (const chunk of streamChat(
|
||||
SUB_AGENT_PROMPT,
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
undefined,
|
||||
timeoutOptions,
|
||||
)) {
|
||||
if (chunk.type === 'text') {
|
||||
rawResponse += chunk.content
|
||||
|
||||
// Forward streaming text to panel
|
||||
emitProgress(plan, progress, callbacks, rawResponse)
|
||||
|
||||
if (animated) {
|
||||
const { results, newOffset } = extractStreamingNodes(
|
||||
rawResponse,
|
||||
streamOffset,
|
||||
)
|
||||
if (results.length > 0) {
|
||||
streamOffset = newOffset
|
||||
startNewAnimationBatch()
|
||||
|
||||
for (const { node, parentId } of results) {
|
||||
// Enforce ID prefix
|
||||
ensureIdPrefix(node, subtask.idPrefix)
|
||||
if (parentId !== null) {
|
||||
// Prefix the parent reference too
|
||||
const prefixedParent = ensurePrefixStr(
|
||||
parentId,
|
||||
subtask.idPrefix,
|
||||
)
|
||||
insertStreamingNode(node, prefixedParent)
|
||||
} else {
|
||||
// Sub-agent root → insert under the orchestrator root frame
|
||||
insertStreamingNode(node, plan.rootFrame.id)
|
||||
if (!subtaskRootId) subtaskRootId = node.id
|
||||
}
|
||||
nodes.push(node)
|
||||
progressEntry.nodeCount++
|
||||
progress.totalNodes++
|
||||
}
|
||||
callbacks?.onApplyPartial?.(progress.totalNodes)
|
||||
// Expand root frame as content grows — children inside sections
|
||||
// don't trigger expandRootFrameHeight from insertStreamingNode
|
||||
// because they insert under the section root, not DEFAULT_FRAME_ID.
|
||||
if (plan.rootFrame.width > 480) {
|
||||
expandRootFrameHeight()
|
||||
}
|
||||
emitProgress(plan, progress, callbacks, rawResponse)
|
||||
}
|
||||
}
|
||||
} else if (chunk.type === 'thinking') {
|
||||
// Forward thinking progress so UI doesn't look stuck
|
||||
emitProgress(plan, progress, callbacks)
|
||||
} else if (chunk.type === 'error') {
|
||||
if (rawResponse.trim().length > 0) {
|
||||
const { recovered } = recoverNodesFromRawResponse(rawResponse, subtask, plan, nodes, progressEntry, progress)
|
||||
if (recovered > 0) {
|
||||
progressEntry.status = 'done'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
return {
|
||||
subtaskId: subtask.id,
|
||||
nodes,
|
||||
rawResponse,
|
||||
error: `partial recovery after error: ${chunk.content}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
progressEntry.status = 'error'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
return { subtaskId: subtask.id, nodes, rawResponse, error: chunk.content }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if streaming extraction found nothing, try batch extraction
|
||||
if (nodes.length === 0 && rawResponse.trim().length > 0) {
|
||||
const fallbackNodes = extractJsonFromResponse(rawResponse)
|
||||
if (fallbackNodes && fallbackNodes.length > 0) {
|
||||
startNewAnimationBatch()
|
||||
for (const node of fallbackNodes) {
|
||||
ensureIdPrefix(node, subtask.idPrefix)
|
||||
insertStreamingNode(node, plan.rootFrame.id)
|
||||
if (!subtaskRootId) subtaskRootId = node.id
|
||||
nodes.push(node)
|
||||
progressEntry.nodeCount++
|
||||
progress.totalNodes++
|
||||
}
|
||||
callbacks?.onApplyPartial?.(progress.totalNodes)
|
||||
}
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
progressEntry.status = 'error'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
console.warn(
|
||||
`[Orchestrator] Subtask "${subtask.label}" produced no parseable nodes. Raw length=${rawResponse.length}`,
|
||||
)
|
||||
return {
|
||||
subtaskId: subtask.id,
|
||||
nodes,
|
||||
rawResponse,
|
||||
error: 'Subtask completed but returned no parseable PenNode output.',
|
||||
}
|
||||
}
|
||||
|
||||
// Apply tree-aware heuristics now that the full subtree is in the store.
|
||||
// During streaming, nodes were inserted individually without children, so
|
||||
// tree-aware heuristics (button width, frame height, clipContent) couldn't run.
|
||||
if (subtaskRootId) {
|
||||
applyPostStreamingTreeHeuristics(subtaskRootId)
|
||||
}
|
||||
|
||||
progressEntry.status = 'done'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
return { subtaskId: subtask.id, nodes, rawResponse }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error'
|
||||
if (rawResponse.trim().length > 0) {
|
||||
const { recovered } = recoverNodesFromRawResponse(rawResponse, subtask, plan, nodes, progressEntry, progress)
|
||||
if (recovered > 0) {
|
||||
progressEntry.status = 'done'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
return {
|
||||
subtaskId: subtask.id,
|
||||
nodes,
|
||||
rawResponse,
|
||||
error: `partial recovery after exception: ${msg}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
progressEntry.status = 'error'
|
||||
emitProgress(plan, progress, callbacks)
|
||||
return { subtaskId: subtask.id, nodes, rawResponse, error: msg }
|
||||
}
|
||||
}
|
||||
|
||||
function recoverNodesFromRawResponse(
|
||||
rawResponse: string,
|
||||
subtask: SubTask,
|
||||
plan: OrchestratorPlan,
|
||||
targetNodes: PenNode[],
|
||||
progressEntry: OrchestrationProgress['subtasks'][number],
|
||||
progress: OrchestrationProgress,
|
||||
): { recovered: number; rootId: string | null } {
|
||||
const fallbackNodes = extractJsonFromResponse(rawResponse)
|
||||
if (!fallbackNodes || fallbackNodes.length === 0) return { recovered: 0, rootId: null }
|
||||
|
||||
let recovered = 0
|
||||
let rootId: string | null = null
|
||||
startNewAnimationBatch()
|
||||
for (const node of fallbackNodes) {
|
||||
ensureIdPrefix(node, subtask.idPrefix)
|
||||
insertStreamingNode(node, plan.rootFrame.id)
|
||||
if (!rootId) rootId = node.id
|
||||
targetNodes.push(node)
|
||||
progressEntry.nodeCount++
|
||||
progress.totalNodes++
|
||||
recovered++
|
||||
}
|
||||
if (rootId) {
|
||||
applyPostStreamingTreeHeuristics(rootId)
|
||||
}
|
||||
return { recovered, rootId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-agent prompt builder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSubAgentUserPrompt(
|
||||
subtask: SubTask,
|
||||
plan: OrchestratorPlan,
|
||||
compactPrompt: string,
|
||||
variables?: Record<string, VariableDefinition>,
|
||||
themes?: Record<string, string[]>,
|
||||
): string {
|
||||
const { region } = subtask
|
||||
|
||||
// Show all sections so the model knows scope — only generate THIS one
|
||||
const sectionList = plan.subtasks
|
||||
.map((st) => `- ${st.label} (${st.region.width}x${st.region.height})${st.id === subtask.id ? ' ← YOU' : ''}`)
|
||||
.join('\n')
|
||||
|
||||
let prompt = `Page sections:\n${sectionList}\n\nGenerate ONLY "${subtask.label}" (~${region.height}px of content).\n${compactPrompt}
|
||||
|
||||
CRITICAL LAYOUT CONSTRAINTS:
|
||||
- Root frame: id="${subtask.idPrefix}-root", width="fill_container", height="fit_content", layout="vertical". NEVER use fixed pixel height on root — let content determine height.
|
||||
- Target content amount: ~${region.height}px tall. Generate enough elements to fill this area.
|
||||
- ALL nodes must be descendants of the root frame. No floating/orphan nodes.
|
||||
- NEVER set x or y on children inside layout frames.
|
||||
- Use "fill_container" for children that stretch, "fit_content" for shrink-wrap sizing.
|
||||
- Use justifyContent="space_between" to distribute items (e.g. navbar: logo | links | CTA). Use padding=[0,80] for horizontal page margins.
|
||||
- For side-by-side layouts, nest a horizontal frame with child frames using "fill_container" width.
|
||||
- Phone mockup = ONE frame node, cornerRadius 32, NO children inside. NEVER use ellipse.
|
||||
- Text height must fit content: estimate lines × fontSize × 1.4.
|
||||
- IDs prefix="${subtask.idPrefix}-". No <step> tags. Output \`\`\`json immediately.`
|
||||
|
||||
// Inject style guide so sub-agent uses consistent colors/fonts
|
||||
if (plan.styleGuide) {
|
||||
const sg = plan.styleGuide
|
||||
const p = sg.palette
|
||||
prompt += `\n\nSTYLE GUIDE (use these consistently):
|
||||
- Background: ${p.background} Surface: ${p.surface}
|
||||
- Text: ${p.text} Secondary: ${p.secondary}
|
||||
- Accent: ${p.accent} Accent2: ${p.accent2} Border: ${p.border}
|
||||
- Heading font: ${sg.fonts.heading} Body font: ${sg.fonts.body}
|
||||
- Aesthetic: ${sg.aesthetic}`
|
||||
}
|
||||
|
||||
const varContext = buildVariableContext(variables, themes)
|
||||
if (varContext) {
|
||||
prompt += '\n\n' + varContext
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ID namespace isolation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureIdPrefix(node: PenNode, prefix: string): void {
|
||||
if (!node.id.startsWith(`${prefix}-`)) {
|
||||
node.id = `${prefix}-${node.id}`
|
||||
}
|
||||
// Recursively prefix children (for fallback tree extraction)
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
ensureIdPrefix(child, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensurePrefixStr(id: string, prefix: string): string {
|
||||
if (id.startsWith(`${prefix}-`)) return id
|
||||
return `${prefix}-${id}`
|
||||
}
|
||||
|
||||
function getRetrySubAgentTimeouts(base: StreamTimeoutConfig): StreamTimeoutConfig {
|
||||
return {
|
||||
...base,
|
||||
hardTimeoutMs: Math.min(
|
||||
base.hardTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
|
||||
RETRY_TIMEOUT_CONFIG.hardTimeoutMaxMs,
|
||||
),
|
||||
noTextTimeoutMs: Math.min(
|
||||
base.noTextTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
|
||||
RETRY_TIMEOUT_CONFIG.noTextTimeoutMaxMs,
|
||||
),
|
||||
firstTextTimeoutMs: base.firstTextTimeoutMs
|
||||
? Math.min(
|
||||
base.firstTextTimeoutMs * RETRY_TIMEOUT_CONFIG.multiplier,
|
||||
RETRY_TIMEOUT_CONFIG.firstTextTimeoutMaxMs,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRetrySubtask(result: SubAgentResult): boolean {
|
||||
if (result.nodes.length > 0) return false
|
||||
if (!result.error) return false
|
||||
const error = result.error.toLowerCase()
|
||||
return error.includes('timed out') || error.includes('thinking too long')
|
||||
}
|
||||
|
||||
function buildMinimalFallbackPrompt(basePrompt: string, label: string): string {
|
||||
return `${basePrompt}
|
||||
|
||||
Fallback mode for section "${label}":
|
||||
- Prioritize completion over detail.
|
||||
- Output a minimal skeleton only (4-8 nodes).
|
||||
- Include: section container, heading, short description, and 1-2 placeholder blocks.
|
||||
- Avoid complex SVG/icon/path details.`
|
||||
}
|
||||
|
||||
function insertLocalSubtaskPlaceholder(
|
||||
subtask: SubTask,
|
||||
plan: OrchestratorPlan,
|
||||
progressEntry: OrchestrationProgress['subtasks'][number],
|
||||
progress: OrchestrationProgress,
|
||||
): PenNode[] {
|
||||
const sectionId = `${subtask.idPrefix}-fallback-section`
|
||||
const titleId = `${subtask.idPrefix}-fallback-title`
|
||||
const descId = `${subtask.idPrefix}-fallback-desc`
|
||||
const rowId = `${subtask.idPrefix}-fallback-row`
|
||||
const cardAId = `${subtask.idPrefix}-fallback-card-a`
|
||||
const cardBId = `${subtask.idPrefix}-fallback-card-b`
|
||||
|
||||
const sectionNode: PenNode = {
|
||||
id: sectionId,
|
||||
type: 'frame',
|
||||
name: `${subtask.label} (Fallback)`,
|
||||
width: 'fill_container',
|
||||
height: Math.max(120, subtask.region.height),
|
||||
layout: 'vertical',
|
||||
padding: 24,
|
||||
gap: 12,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E2E8F0' }] },
|
||||
children: [],
|
||||
}
|
||||
|
||||
const titleNode: PenNode = {
|
||||
id: titleId,
|
||||
type: 'text',
|
||||
name: 'Fallback Title',
|
||||
content: subtask.label,
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
width: 'fill_container',
|
||||
height: 32,
|
||||
fill: [{ type: 'solid', color: '#0F172A' }],
|
||||
}
|
||||
|
||||
const descNode: PenNode = {
|
||||
id: descId,
|
||||
type: 'text',
|
||||
name: 'Fallback Description',
|
||||
content: 'Content is loading from a complex generation task. This placeholder keeps layout continuity.',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
width: 'fill_container',
|
||||
height: 40,
|
||||
fill: [{ type: 'solid', color: '#64748B' }],
|
||||
}
|
||||
|
||||
const rowNode: PenNode = {
|
||||
id: rowId,
|
||||
type: 'frame',
|
||||
name: 'Fallback Row',
|
||||
width: 'fill_container',
|
||||
height: 120,
|
||||
layout: 'horizontal',
|
||||
gap: 12,
|
||||
children: [],
|
||||
}
|
||||
|
||||
const cardANode: PenNode = {
|
||||
id: cardAId,
|
||||
type: 'rectangle',
|
||||
name: 'Fallback Card A',
|
||||
width: 'fill_container',
|
||||
height: 120,
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#F1F5F9' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] },
|
||||
}
|
||||
|
||||
const cardBNode: PenNode = {
|
||||
id: cardBId,
|
||||
type: 'rectangle',
|
||||
name: 'Fallback Card B',
|
||||
width: 'fill_container',
|
||||
height: 120,
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#F1F5F9' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#CBD5E1' }] },
|
||||
}
|
||||
|
||||
const insertedNodes = [sectionNode, titleNode, descNode, rowNode, cardANode, cardBNode]
|
||||
|
||||
startNewAnimationBatch()
|
||||
insertStreamingNode(sectionNode, plan.rootFrame.id)
|
||||
insertStreamingNode(titleNode, sectionId)
|
||||
insertStreamingNode(descNode, sectionId)
|
||||
insertStreamingNode(rowNode, sectionId)
|
||||
insertStreamingNode(cardANode, rowId)
|
||||
insertStreamingNode(cardBNode, rowId)
|
||||
|
||||
progressEntry.nodeCount += insertedNodes.length
|
||||
progress.totalNodes += insertedNodes.length
|
||||
progressEntry.status = 'done'
|
||||
|
||||
console.warn(
|
||||
`[Orchestrator] Inserted local placeholder for subtask "${subtask.label}" after repeated empty output.`,
|
||||
)
|
||||
|
||||
return insertedNodes
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress emission — updates UI via <step> tags
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function emitProgress(
|
||||
plan: OrchestratorPlan,
|
||||
progress: OrchestrationProgress,
|
||||
callbacks?: {
|
||||
onTextUpdate?: (text: string) => void
|
||||
},
|
||||
streamingText?: string,
|
||||
): void {
|
||||
if (!callbacks?.onTextUpdate) return
|
||||
|
||||
// Always show "Planning layout" as done first
|
||||
const planningStep = '<step title="Planning layout" status="done">Analyzing design structure...</step>'
|
||||
|
||||
const subtaskSteps = plan.subtasks
|
||||
.map((st, i) => {
|
||||
const entry = progress.subtasks[i]
|
||||
const status = entry.status === 'streaming' ? 'streaming'
|
||||
: entry.status === 'done' ? 'done'
|
||||
: entry.status === 'error' ? 'error'
|
||||
: 'pending'
|
||||
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
|
||||
return `<step title="${st.label}${nodeInfo}" status="${status}"></step>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
let output = `${planningStep}\n${subtaskSteps}`
|
||||
if (streamingText) {
|
||||
output += '\n\n' + streamingText
|
||||
}
|
||||
callbacks.onTextUpdate(output)
|
||||
}
|
||||
|
||||
/** Build step tags for the final rawResponse (shown in message after streaming ends) */
|
||||
function buildFinalStepTags(
|
||||
plan: OrchestratorPlan,
|
||||
progress: OrchestrationProgress,
|
||||
): string {
|
||||
const planningStep = '<step title="Planning layout" status="done">Analyzing design structure...</step>'
|
||||
const subtaskSteps = plan.subtasks
|
||||
.map((st, i) => {
|
||||
const entry = progress.subtasks[i]
|
||||
const status = entry.status
|
||||
const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
|
||||
return `<step title="${st.label}${nodeInfo}" status="${status}"></step>`
|
||||
})
|
||||
.join('\n')
|
||||
return `${planningStep}\n${subtaskSteps}`
|
||||
}
|
||||
|
|
@ -138,6 +138,9 @@ function layoutToCSS(node: ContainerProps): Record<string, string> {
|
|||
}
|
||||
css['align-items'] = map[node.alignItems] ?? node.alignItems
|
||||
}
|
||||
if (node.clipContent) {
|
||||
css.overflow = 'hidden'
|
||||
}
|
||||
return css
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +243,10 @@ function generateNodeHTML(
|
|||
if (node.fontFamily) css['font-family'] = `'${node.fontFamily}', sans-serif`
|
||||
if (node.lineHeight) css['line-height'] = String(node.lineHeight)
|
||||
if (node.letterSpacing) css['letter-spacing'] = `${node.letterSpacing}px`
|
||||
if (node.textAlignVertical === 'middle') css['vertical-align'] = 'middle'
|
||||
else if (node.textAlignVertical === 'bottom') css['vertical-align'] = 'bottom'
|
||||
if (node.textGrowth === 'auto') css['white-space'] = 'nowrap'
|
||||
else if (node.textGrowth === 'fixed-width-height') css.overflow = 'hidden'
|
||||
if (node.underline) css['text-decoration'] = 'underline'
|
||||
if (node.strikethrough) css['text-decoration'] = 'line-through'
|
||||
Object.assign(css, effectsToCSS(node.effects))
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ function layoutToTailwind(node: ContainerProps): string[] {
|
|||
}
|
||||
if (aiMap[node.alignItems]) classes.push(aiMap[node.alignItems])
|
||||
}
|
||||
if (node.clipContent) {
|
||||
classes.push('overflow-hidden')
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
|
|
@ -193,6 +196,10 @@ function textToTailwind(node: TextNode): string[] {
|
|||
if (node.fontFamily) classes.push(`font-['${node.fontFamily.replace(/\s/g, '_')}']`)
|
||||
if (node.lineHeight) classes.push(`leading-[${node.lineHeight}]`)
|
||||
if (node.letterSpacing) classes.push(`tracking-[${node.letterSpacing}px]`)
|
||||
if (node.textAlignVertical === 'middle') classes.push('align-middle')
|
||||
else if (node.textAlignVertical === 'bottom') classes.push('align-bottom')
|
||||
if (node.textGrowth === 'auto') classes.push('whitespace-nowrap')
|
||||
else if (node.textGrowth === 'fixed-width-height') classes.push('overflow-hidden')
|
||||
if (node.underline) classes.push('underline')
|
||||
if (node.strikethrough) classes.push('line-through')
|
||||
return classes
|
||||
|
|
|
|||
|
|
@ -43,6 +43,13 @@ const DEFAULT_PROVIDERS: Record<AIProviderType, AIProviderConfig> = {
|
|||
connectionMethod: null,
|
||||
models: [],
|
||||
},
|
||||
opencode: {
|
||||
type: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
isConnected: false,
|
||||
connectionMethod: null,
|
||||
models: [],
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_MCP_INTEGRATIONS: MCPCliIntegration[] = [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { create } from 'zustand'
|
||||
import { nanoid } from 'nanoid'
|
||||
import type { PenDocument, PenNode, GroupNode } from '@/types/pen'
|
||||
import type { PenDocument, PenNode, GroupNode, RefNode } from '@/types/pen'
|
||||
import type { VariableDefinition } from '@/types/variables'
|
||||
import { useHistoryStore } from '@/stores/history-store'
|
||||
import { getDefaultTheme } from '@/variables/resolve-variables'
|
||||
|
|
@ -98,6 +98,71 @@ function flattenNodes(nodes: PenNode[]): PenNode[] {
|
|||
return result
|
||||
}
|
||||
|
||||
/** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */
|
||||
function getNodeBounds(
|
||||
node: PenNode,
|
||||
allNodes: PenNode[],
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const x = node.x ?? 0
|
||||
const y = node.y ?? 0
|
||||
let w = ('width' in node && typeof node.width === 'number') ? node.width : 0
|
||||
let h = ('height' in node && typeof node.height === 'number') ? node.height : 0
|
||||
if (node.type === 'ref' && !w) {
|
||||
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
|
||||
if (refComp) {
|
||||
w = ('width' in refComp && typeof refComp.width === 'number') ? refComp.width : 100
|
||||
h = ('height' in refComp && typeof refComp.height === 'number') ? refComp.height : 100
|
||||
}
|
||||
}
|
||||
return { x, y, w: w || 100, h: h || 100 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a clear X position to the right of `sourceX + sourceW` that doesn't
|
||||
* overlap any sibling (excluding `excludeId`) on the same vertical band.
|
||||
*/
|
||||
function findClearX(
|
||||
sourceX: number,
|
||||
sourceW: number,
|
||||
proposedY: number,
|
||||
proposedH: number,
|
||||
siblings: PenNode[],
|
||||
excludeId: string,
|
||||
allNodes: PenNode[],
|
||||
gap = 20,
|
||||
): number {
|
||||
const proposedW = sourceW
|
||||
let proposedX = sourceX + sourceW + gap
|
||||
|
||||
const siblingBounds: { x: number; y: number; w: number; h: number }[] = []
|
||||
for (const sib of siblings) {
|
||||
if (sib.id === excludeId) continue
|
||||
const b = getNodeBounds(sib, allNodes)
|
||||
if (b.w > 0 && b.h > 0) siblingBounds.push(b)
|
||||
}
|
||||
|
||||
let maxAttempts = 100
|
||||
while (maxAttempts-- > 0) {
|
||||
const hasOverlap = siblingBounds.some((b) => {
|
||||
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
|
||||
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
|
||||
return overlapX && overlapY
|
||||
})
|
||||
if (!hasOverlap) break
|
||||
let maxRight = proposedX
|
||||
for (const b of siblingBounds) {
|
||||
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
|
||||
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
|
||||
if (overlapX && overlapY && b.x + b.w > maxRight) {
|
||||
maxRight = b.x + b.w
|
||||
}
|
||||
}
|
||||
proposedX = maxRight + gap
|
||||
}
|
||||
|
||||
return proposedX
|
||||
}
|
||||
|
||||
function isDescendantOf(
|
||||
nodes: PenNode[],
|
||||
nodeId: string,
|
||||
|
|
@ -129,8 +194,8 @@ function insertNodeInTree(
|
|||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
if (n.id === parentId && 'children' in n) {
|
||||
const children = [...(n.children ?? [])]
|
||||
if (n.id === parentId) {
|
||||
const children = 'children' in n && n.children ? [...n.children] : []
|
||||
if (index !== undefined) {
|
||||
children.splice(index, 0, node)
|
||||
} else {
|
||||
|
|
@ -234,6 +299,10 @@ interface DocumentStoreState {
|
|||
getFlatNodes: () => PenNode[]
|
||||
isDescendantOf: (nodeId: string, ancestorId: string) => boolean
|
||||
|
||||
// Component management
|
||||
makeReusable: (nodeId: string) => void
|
||||
detachComponent: (nodeId: string) => string | undefined
|
||||
|
||||
// Variable management
|
||||
setVariable: (name: string, definition: VariableDefinition) => void
|
||||
removeVariable: (name: string) => void
|
||||
|
|
@ -398,6 +467,47 @@ export const useDocumentStore = create<DocumentStoreState>(
|
|||
const node = findNodeInTree(state.document.children, id)
|
||||
if (!node) return null
|
||||
|
||||
// Duplicating a reusable component creates an instance (RefNode)
|
||||
if ('reusable' in node && node.reusable === true) {
|
||||
const bounds = getNodeBounds(node, state.document.children)
|
||||
const parent = findParentInTree(state.document.children, id)
|
||||
const parentId = parent ? parent.id : null
|
||||
const siblings = parent
|
||||
? ('children' in parent ? parent.children ?? [] : [])
|
||||
: state.document.children
|
||||
const idx = siblings.findIndex((n) => n.id === id)
|
||||
|
||||
const clearX = findClearX(
|
||||
bounds.x, bounds.w, bounds.y, bounds.h,
|
||||
siblings, id, state.document.children,
|
||||
)
|
||||
|
||||
const refNode: RefNode = {
|
||||
id: nanoid(),
|
||||
type: 'ref',
|
||||
ref: node.id,
|
||||
name: node.name ?? node.type,
|
||||
x: clearX,
|
||||
y: bounds.y,
|
||||
}
|
||||
|
||||
useHistoryStore.getState().pushState(state.document)
|
||||
set((s) => ({
|
||||
document: {
|
||||
...s.document,
|
||||
children: insertNodeInTree(
|
||||
s.document.children,
|
||||
parentId,
|
||||
refNode as PenNode,
|
||||
idx + 1,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
}))
|
||||
return refNode.id
|
||||
}
|
||||
|
||||
// Regular duplication for non-reusable nodes
|
||||
const cloneWithNewIds = (n: PenNode): PenNode => {
|
||||
const cloned = { ...n, id: nanoid() } as PenNode
|
||||
if ('children' in cloned && cloned.children) {
|
||||
|
|
@ -408,8 +518,6 @@ export const useDocumentStore = create<DocumentStoreState>(
|
|||
|
||||
const clone = cloneWithNewIds(node)
|
||||
clone.name = (clone.name ?? clone.type) + ' copy'
|
||||
if (clone.x !== undefined) clone.x += 10
|
||||
if (clone.y !== undefined) clone.y += 10
|
||||
|
||||
const parent = findParentInTree(state.document.children, id)
|
||||
const parentId = parent ? parent.id : null
|
||||
|
|
@ -418,6 +526,13 @@ export const useDocumentStore = create<DocumentStoreState>(
|
|||
: state.document.children
|
||||
const idx = siblings.findIndex((n) => n.id === id)
|
||||
|
||||
const bounds = getNodeBounds(node, state.document.children)
|
||||
clone.x = findClearX(
|
||||
bounds.x, bounds.w, bounds.y, bounds.h,
|
||||
siblings, id, state.document.children,
|
||||
)
|
||||
clone.y = bounds.y
|
||||
|
||||
useHistoryStore.getState().pushState(state.document)
|
||||
set((s) => ({
|
||||
document: {
|
||||
|
|
@ -596,6 +711,113 @@ export const useDocumentStore = create<DocumentStoreState>(
|
|||
isDescendantOf: (nodeId, ancestorId) =>
|
||||
isDescendantOf(get().document.children, nodeId, ancestorId),
|
||||
|
||||
// --- Component management ---
|
||||
|
||||
makeReusable: (nodeId) => {
|
||||
const state = get()
|
||||
const node = findNodeInTree(state.document.children, nodeId)
|
||||
if (!node) return
|
||||
// Only container types (frame, group, rectangle) can be made reusable
|
||||
if (node.type !== 'frame' && node.type !== 'group' && node.type !== 'rectangle') return
|
||||
if ('reusable' in node && node.reusable) return
|
||||
useHistoryStore.getState().pushState(state.document)
|
||||
set((s) => ({
|
||||
document: {
|
||||
...s.document,
|
||||
children: updateNodeInTree(s.document.children, nodeId, {
|
||||
reusable: true,
|
||||
} as Partial<PenNode>),
|
||||
},
|
||||
isDirty: true,
|
||||
}))
|
||||
},
|
||||
|
||||
detachComponent: (nodeId) => {
|
||||
const state = get()
|
||||
const node = findNodeInTree(state.document.children, nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Case 1: Detach a reusable component (remove reusable flag)
|
||||
if ('reusable' in node && node.reusable) {
|
||||
useHistoryStore.getState().pushState(state.document)
|
||||
set((s) => ({
|
||||
document: {
|
||||
...s.document,
|
||||
children: updateNodeInTree(s.document.children, nodeId, {
|
||||
reusable: undefined,
|
||||
} as Partial<PenNode>),
|
||||
},
|
||||
isDirty: true,
|
||||
}))
|
||||
return nodeId
|
||||
}
|
||||
|
||||
// Case 2: Detach an instance (RefNode → independent node tree)
|
||||
if (node.type === 'ref') {
|
||||
const component = findNodeInTree(state.document.children, node.ref)
|
||||
if (!component) return
|
||||
|
||||
useHistoryStore.getState().pushState(state.document)
|
||||
|
||||
// Apply overrides to a copy of the component before cloning IDs
|
||||
const source = structuredClone(component)
|
||||
// Apply top-level visual overrides (fill, stroke, etc.)
|
||||
const topOverrides = node.descendants?.[node.ref]
|
||||
if (topOverrides) {
|
||||
Object.assign(source, topOverrides)
|
||||
}
|
||||
// Apply child-level overrides
|
||||
if (node.descendants && 'children' in source && source.children) {
|
||||
source.children = source.children.map((child: PenNode) => {
|
||||
const override = node.descendants?.[child.id]
|
||||
return override ? ({ ...child, ...override } as PenNode) : child
|
||||
})
|
||||
}
|
||||
|
||||
// Clone with new IDs
|
||||
const cloneWithNewIds = (n: PenNode): PenNode => {
|
||||
const cloned = { ...n, id: nanoid() } as PenNode
|
||||
if ('children' in cloned && cloned.children) {
|
||||
cloned.children = cloned.children.map(cloneWithNewIds)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
const detached = cloneWithNewIds(source)
|
||||
// Apply all direct instance properties (position, size, meta)
|
||||
const detachedRecord = detached as unknown as Record<string, unknown>
|
||||
for (const [key, val] of Object.entries(node)) {
|
||||
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children' || key === 'id') continue
|
||||
if (val !== undefined) {
|
||||
detachedRecord[key] = val
|
||||
}
|
||||
}
|
||||
if (!detached.name) detached.name = source.name
|
||||
delete detachedRecord.reusable
|
||||
|
||||
// Replace the RefNode with the detached tree
|
||||
const parent = findParentInTree(state.document.children, nodeId)
|
||||
const parentId = parent ? parent.id : null
|
||||
const siblings = parent
|
||||
? ('children' in parent ? parent.children ?? [] : [])
|
||||
: state.document.children
|
||||
const idx = siblings.findIndex((n) => n.id === nodeId)
|
||||
|
||||
let newChildren = removeNodeFromTree(state.document.children, nodeId)
|
||||
newChildren = insertNodeInTree(
|
||||
newChildren,
|
||||
parentId,
|
||||
detached,
|
||||
idx >= 0 ? idx : undefined,
|
||||
)
|
||||
|
||||
set({
|
||||
document: { ...state.document, children: newChildren },
|
||||
isDirty: true,
|
||||
})
|
||||
return detached.id
|
||||
}
|
||||
},
|
||||
|
||||
// --- Variable management ---
|
||||
|
||||
setVariable: (name, definition) => {
|
||||
|
|
|
|||
87
src/stores/uikit-store.ts
Normal file
87
src/stores/uikit-store.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { create } from 'zustand'
|
||||
import type { UIKit, ComponentCategory } from '@/types/uikit'
|
||||
import { getBuiltInKits } from '@/uikit/built-in-registry'
|
||||
|
||||
const STORAGE_KEY = 'openpencil-uikits'
|
||||
|
||||
interface PersistedState {
|
||||
importedKits: UIKit[]
|
||||
}
|
||||
|
||||
interface UIKitStoreState {
|
||||
/** All loaded kits (built-in + imported) */
|
||||
kits: UIKit[]
|
||||
/** Whether the browser panel is open */
|
||||
browserOpen: boolean
|
||||
/** Current search query */
|
||||
searchQuery: string
|
||||
/** Active category filter (null = all) */
|
||||
activeCategory: ComponentCategory | null
|
||||
/** Active kit filter (null = all) */
|
||||
activeKitId: string | null
|
||||
|
||||
toggleBrowser: () => void
|
||||
setBrowserOpen: (open: boolean) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setActiveCategory: (category: ComponentCategory | null) => void
|
||||
setActiveKitId: (kitId: string | null) => void
|
||||
importKit: (kit: UIKit) => void
|
||||
removeKit: (kitId: string) => void
|
||||
persist: () => void
|
||||
hydrate: () => void
|
||||
}
|
||||
|
||||
export const useUIKitStore = create<UIKitStoreState>((set, get) => ({
|
||||
kits: getBuiltInKits(),
|
||||
browserOpen: false,
|
||||
searchQuery: '',
|
||||
activeCategory: null,
|
||||
activeKitId: null,
|
||||
|
||||
toggleBrowser: () => set((s) => ({ browserOpen: !s.browserOpen })),
|
||||
setBrowserOpen: (open) => set({ browserOpen: open }),
|
||||
setSearchQuery: (searchQuery) => set({ searchQuery }),
|
||||
setActiveCategory: (activeCategory) => set({ activeCategory }),
|
||||
setActiveKitId: (activeKitId) => set({ activeKitId }),
|
||||
|
||||
importKit: (kit) => {
|
||||
set((s) => ({ kits: [...s.kits, kit] }))
|
||||
get().persist()
|
||||
},
|
||||
|
||||
removeKit: (kitId) => {
|
||||
const { activeKitId } = get()
|
||||
set((s) => ({
|
||||
kits: s.kits.filter((k) => k.id !== kitId || k.builtIn),
|
||||
// Reset filter if the deleted kit was selected
|
||||
activeKitId: activeKitId === kitId ? null : activeKitId,
|
||||
}))
|
||||
get().persist()
|
||||
},
|
||||
|
||||
persist: () => {
|
||||
try {
|
||||
const imported = get().kits.filter((k) => !k.builtIn)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ importedKits: imported }))
|
||||
} catch {
|
||||
// ignore — localStorage may be full
|
||||
}
|
||||
},
|
||||
|
||||
hydrate: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return
|
||||
const data = JSON.parse(raw) as Partial<PersistedState>
|
||||
if (data.importedKits && Array.isArray(data.importedKits)) {
|
||||
const builtIn = getBuiltInKits()
|
||||
const builtInIds = new Set(builtIn.map((k) => k.id))
|
||||
// Filter out any imported kits that clash with built-in IDs
|
||||
const imported = data.importedKits.filter((k) => !builtInIds.has(k.id))
|
||||
set({ kits: [...builtIn, ...imported] })
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
|
@ -108,3 +108,8 @@ code {
|
|||
.syn-comment { color: #546e7a; font-style: italic; }
|
||||
.syn-number { color: #f78c6c; }
|
||||
.syn-bracket { color: #89ddff; }
|
||||
|
||||
/* Electron window drag regions */
|
||||
.app-region-drag { -webkit-app-region: drag; }
|
||||
.app-region-no-drag { -webkit-app-region: no-drag; }
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
export type AIProviderType = 'anthropic' | 'openai'
|
||||
export type AIProviderType = 'anthropic' | 'openai' | 'opencode'
|
||||
|
||||
export interface AIProviderConfig {
|
||||
type: AIProviderType
|
||||
displayName: string
|
||||
isConnected: boolean
|
||||
connectionMethod: 'claude-code' | 'codex-cli' | null
|
||||
connectionMethod: 'claude-code' | 'codex-cli' | 'opencode' | null
|
||||
/** Models fetched when the user connects this provider */
|
||||
models: GroupedModel[]
|
||||
}
|
||||
|
|
|
|||
13
src/types/electron.d.ts
vendored
Normal file
13
src/types/electron.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface ElectronAPI {
|
||||
isElectron: true
|
||||
openFile: () => Promise<{ filePath: string; content: string } | null>
|
||||
saveFile: (
|
||||
content: string,
|
||||
defaultPath?: string,
|
||||
) => Promise<string | null>
|
||||
saveToPath: (filePath: string, content: string) => Promise<string>
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI
|
||||
}
|
||||
82
src/types/opencode-sdk.d.ts
vendored
Normal file
82
src/types/opencode-sdk.d.ts
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Minimal type declarations for @opencode-ai/sdk.
|
||||
* The package's export map points to non-existent paths (dist/ vs dist/src/).
|
||||
* A postinstall script patches this, but we keep declarations as fallback.
|
||||
*/
|
||||
|
||||
interface OpencodeClient {
|
||||
config: {
|
||||
providers(options?: unknown): Promise<{
|
||||
data: {
|
||||
providers: OpencodeProvider[]
|
||||
default: Record<string, string>
|
||||
}
|
||||
error: unknown
|
||||
}>
|
||||
}
|
||||
session: {
|
||||
create(options?: {
|
||||
body?: { parentID?: string; title?: string }
|
||||
}): Promise<{
|
||||
data: OpencodeSession | undefined
|
||||
error: unknown
|
||||
}>
|
||||
prompt(options: {
|
||||
path: { id: string }
|
||||
body: {
|
||||
model?: { providerID: string; modelID: string }
|
||||
noReply?: boolean
|
||||
parts: Array<{ type: string; text: string }>
|
||||
}
|
||||
}): Promise<{
|
||||
data:
|
||||
| {
|
||||
info: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string } & Record<string, unknown>>
|
||||
}
|
||||
| undefined
|
||||
error: unknown
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
interface OpencodeProvider {
|
||||
id: string
|
||||
name: string
|
||||
models: Record<string, OpencodeModel>
|
||||
}
|
||||
|
||||
interface OpencodeModel {
|
||||
id: string
|
||||
name: string
|
||||
providerID: string
|
||||
}
|
||||
|
||||
interface OpencodeSession {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
declare module '@opencode-ai/sdk' {
|
||||
export function createOpencode(options?: {
|
||||
hostname?: string
|
||||
port?: number
|
||||
signal?: AbortSignal
|
||||
timeout?: number
|
||||
}): Promise<{
|
||||
client: OpencodeClient
|
||||
server: { url: string; close(): void }
|
||||
}>
|
||||
|
||||
export function createOpencodeClient(config?: {
|
||||
baseUrl?: string
|
||||
directory?: string
|
||||
}): OpencodeClient
|
||||
}
|
||||
|
||||
declare module '@opencode-ai/sdk/client' {
|
||||
export function createOpencodeClient(config?: {
|
||||
baseUrl?: string
|
||||
directory?: string
|
||||
}): OpencodeClient
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ export interface ContainerProps {
|
|||
| 'space_between'
|
||||
| 'space_around'
|
||||
alignItems?: 'start' | 'center' | 'end'
|
||||
clipContent?: boolean
|
||||
children?: PenNode[]
|
||||
cornerRadius?: number | [number, number, number, number]
|
||||
fill?: PenFill[]
|
||||
|
|
|
|||
42
src/types/uikit.ts
Normal file
42
src/types/uikit.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { PenDocument } from './pen'
|
||||
|
||||
export type ComponentCategory =
|
||||
| 'buttons'
|
||||
| 'inputs'
|
||||
| 'cards'
|
||||
| 'navigation'
|
||||
| 'layout'
|
||||
| 'feedback'
|
||||
| 'data-display'
|
||||
| 'other'
|
||||
|
||||
export interface KitComponent {
|
||||
/** Node ID of the reusable FrameNode in the kit document */
|
||||
id: string
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Category for organization in the browser */
|
||||
category: ComponentCategory
|
||||
/** Tags for search */
|
||||
tags: string[]
|
||||
/** Component dimensions for preview sizing */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface UIKit {
|
||||
/** Unique identifier */
|
||||
id: string
|
||||
/** Display name */
|
||||
name: string
|
||||
/** Optional description */
|
||||
description?: string
|
||||
/** Version string */
|
||||
version: string
|
||||
/** Whether this is a built-in kit that ships with the app */
|
||||
builtIn: boolean
|
||||
/** Backing PenDocument containing the reusable nodes */
|
||||
document: PenDocument
|
||||
/** Extracted component metadata for browsing */
|
||||
components: KitComponent[]
|
||||
}
|
||||
18
src/uikit/built-in-registry.ts
Normal file
18
src/uikit/built-in-registry.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { UIKit } from '@/types/uikit'
|
||||
import { DEFAULT_KIT_DOCUMENT } from './kits/default-kit'
|
||||
import { DEFAULT_KIT_META } from './kits/default-kit-meta'
|
||||
import { extractComponentsFromDocument } from './kit-utils'
|
||||
|
||||
const defaultKit: UIKit = {
|
||||
id: 'default-uikit',
|
||||
name: 'Default UIKit',
|
||||
description: 'Built-in UI components: buttons, inputs, cards, navigation, feedback, and layout primitives.',
|
||||
version: '1.0.0',
|
||||
builtIn: true,
|
||||
document: DEFAULT_KIT_DOCUMENT,
|
||||
components: extractComponentsFromDocument(DEFAULT_KIT_DOCUMENT, DEFAULT_KIT_META),
|
||||
}
|
||||
|
||||
export function getBuiltInKits(): UIKit[] {
|
||||
return [defaultKit]
|
||||
}
|
||||
195
src/uikit/kit-import-export.ts
Normal file
195
src/uikit/kit-import-export.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { nanoid } from 'nanoid'
|
||||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
import type { UIKit } from '@/types/uikit'
|
||||
import { normalizePenDocument } from '@/utils/normalize-pen-file'
|
||||
import {
|
||||
supportsFileSystemAccess,
|
||||
saveDocumentAs,
|
||||
downloadDocument,
|
||||
} from '@/utils/file-operations'
|
||||
import { extractComponentsFromDocument } from './kit-utils'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Import a .pen file as a UIKit via the native file picker.
|
||||
* Returns null if the user cancels or the file has no reusable components.
|
||||
*/
|
||||
export async function importKitFromFile(): Promise<UIKit | null> {
|
||||
const doc = await pickAndParsePenFile()
|
||||
if (!doc) return null
|
||||
|
||||
const components = extractComponentsFromDocument(doc)
|
||||
if (components.length === 0) return null
|
||||
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: doc.name ?? 'Imported Kit',
|
||||
version: doc.version,
|
||||
builtIn: false,
|
||||
document: doc,
|
||||
components,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Export reusable components from the current document as a .pen kit file.
|
||||
* If componentIds is empty, exports all reusable components.
|
||||
*/
|
||||
export async function exportKit(
|
||||
sourceDoc: PenDocument,
|
||||
componentIds: string[],
|
||||
kitName: string,
|
||||
): Promise<boolean> {
|
||||
const reusableNodes = collectReusableNodes(sourceDoc.children)
|
||||
const selected =
|
||||
componentIds.length > 0
|
||||
? reusableNodes.filter((n) => componentIds.includes(n.id))
|
||||
: reusableNodes
|
||||
|
||||
if (selected.length === 0) return false
|
||||
|
||||
const kitDoc: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: kitName,
|
||||
children: selected.map((n) => structuredClone(n)),
|
||||
}
|
||||
|
||||
// Copy referenced variables
|
||||
if (sourceDoc.variables) {
|
||||
const refs = collectAllVariableRefs(selected)
|
||||
const vars: Record<string, unknown> = {}
|
||||
for (const ref of refs) {
|
||||
const name = ref.startsWith('$') ? ref.slice(1) : ref
|
||||
if (sourceDoc.variables[name]) {
|
||||
vars[name] = structuredClone(sourceDoc.variables[name])
|
||||
}
|
||||
}
|
||||
if (Object.keys(vars).length > 0) {
|
||||
kitDoc.variables = vars as PenDocument['variables']
|
||||
}
|
||||
}
|
||||
|
||||
// Copy themes if variables were included
|
||||
if (kitDoc.variables && sourceDoc.themes) {
|
||||
kitDoc.themes = structuredClone(sourceDoc.themes)
|
||||
}
|
||||
|
||||
const fileName = `${kitName.replace(/[^a-zA-Z0-9-_ ]/g, '').trim() || 'kit'}.op`
|
||||
|
||||
if (supportsFileSystemAccess()) {
|
||||
const result = await saveDocumentAs(kitDoc, fileName)
|
||||
return result !== null
|
||||
}
|
||||
downloadDocument(kitDoc, fileName)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function pickAndParsePenFile(): Promise<PenDocument | null> {
|
||||
if (supportsFileSystemAccess()) {
|
||||
return pickFileSystemAccess()
|
||||
}
|
||||
return pickFallback()
|
||||
}
|
||||
|
||||
async function pickFileSystemAccess(): Promise<PenDocument | null> {
|
||||
try {
|
||||
const [handle] = await (
|
||||
window as unknown as {
|
||||
showOpenFilePicker: (opts: unknown) => Promise<FileSystemFileHandle[]>
|
||||
}
|
||||
).showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'OpenPencil File',
|
||||
accept: { 'application/json': ['.op', '.pen', '.json'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
const file = await handle.getFile()
|
||||
const text = await file.text()
|
||||
return parsePenJson(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function pickFallback(): Promise<PenDocument | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.op,.pen,.json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) { resolve(null); return }
|
||||
try {
|
||||
const text = await file.text()
|
||||
resolve(parsePenJson(text))
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
function parsePenJson(text: string): PenDocument | null {
|
||||
try {
|
||||
const raw = JSON.parse(text) as PenDocument
|
||||
if (!raw.version || !Array.isArray(raw.children)) return null
|
||||
return normalizePenDocument(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function collectReusableNodes(nodes: PenNode[]): PenNode[] {
|
||||
const result: PenNode[] = []
|
||||
for (const node of nodes) {
|
||||
if ('reusable' in node && node.reusable) {
|
||||
result.push(node)
|
||||
}
|
||||
if ('children' in node && node.children) {
|
||||
result.push(...collectReusableNodes(node.children))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isVarRef(v: unknown): v is string {
|
||||
return typeof v === 'string' && v.startsWith('$')
|
||||
}
|
||||
|
||||
function collectAllVariableRefs(nodes: PenNode[]): Set<string> {
|
||||
const refs = new Set<string>()
|
||||
for (const node of nodes) {
|
||||
collectNodeRefs(node, refs)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
function collectNodeRefs(node: PenNode, refs: Set<string>): void {
|
||||
if (isVarRef(node.opacity)) refs.add(node.opacity)
|
||||
if (isVarRef(node.enabled)) refs.add(node.enabled as string)
|
||||
if ('gap' in node && isVarRef(node.gap)) refs.add(node.gap as string)
|
||||
if ('padding' in node && isVarRef(node.padding)) refs.add(node.padding as string)
|
||||
if ('fill' in node && Array.isArray(node.fill)) {
|
||||
for (const f of node.fill) {
|
||||
if (f.type === 'solid' && isVarRef(f.color)) refs.add(f.color)
|
||||
}
|
||||
}
|
||||
if ('children' in node && node.children) {
|
||||
for (const child of node.children) collectNodeRefs(child, refs)
|
||||
}
|
||||
}
|
||||
139
src/uikit/kit-utils.ts
Normal file
139
src/uikit/kit-utils.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
import type { ComponentCategory, KitComponent } from '@/types/uikit'
|
||||
|
||||
/**
|
||||
* Walk the document tree and extract all reusable nodes as KitComponent metadata.
|
||||
*/
|
||||
export function extractComponentsFromDocument(
|
||||
doc: PenDocument,
|
||||
metaOverrides?: Record<string, { category: ComponentCategory; tags: string[] }>,
|
||||
): KitComponent[] {
|
||||
const components: KitComponent[] = []
|
||||
walkTree(doc.children, (node) => {
|
||||
if ('reusable' in node && node.reusable) {
|
||||
const w = 'width' in node && typeof node.width === 'number' ? node.width : 100
|
||||
const h = 'height' in node && typeof node.height === 'number' ? node.height : 100
|
||||
const meta = metaOverrides?.[node.id]
|
||||
components.push({
|
||||
id: node.id,
|
||||
name: node.name ?? node.id,
|
||||
category: meta?.category ?? inferCategory(node.name ?? ''),
|
||||
tags: meta?.tags ?? inferTags(node.name ?? ''),
|
||||
width: w,
|
||||
height: h,
|
||||
})
|
||||
}
|
||||
})
|
||||
return components
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific node in a document tree by ID.
|
||||
*/
|
||||
export function findReusableNode(doc: PenDocument, nodeId: string): PenNode | undefined {
|
||||
return findInTree(doc.children, nodeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect all $variable references used by a node tree.
|
||||
*/
|
||||
export function collectVariableRefs(node: PenNode): Set<string> {
|
||||
const refs = new Set<string>()
|
||||
collectRefs(node, refs)
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a node tree preserving all IDs.
|
||||
*/
|
||||
export function deepCloneNode<T extends PenNode>(node: T): T {
|
||||
return structuredClone(node)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function walkTree(nodes: PenNode[], visitor: (node: PenNode) => void): void {
|
||||
for (const node of nodes) {
|
||||
visitor(node)
|
||||
if ('children' in node && node.children) {
|
||||
walkTree(node.children, visitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findInTree(nodes: PenNode[], id: string): PenNode | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if ('children' in node && node.children) {
|
||||
const found = findInTree(node.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function isVariableRef(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.startsWith('$')
|
||||
}
|
||||
|
||||
function collectRefs(node: PenNode, refs: Set<string>): void {
|
||||
if (isVariableRef(node.opacity)) refs.add(node.opacity)
|
||||
if (isVariableRef(node.enabled)) refs.add(node.enabled)
|
||||
|
||||
if ('gap' in node && isVariableRef(node.gap)) refs.add(node.gap as string)
|
||||
if ('padding' in node && isVariableRef(node.padding)) refs.add(node.padding as string)
|
||||
|
||||
if ('fill' in node && Array.isArray(node.fill)) {
|
||||
for (const f of node.fill) {
|
||||
if (f.type === 'solid' && isVariableRef(f.color)) refs.add(f.color)
|
||||
}
|
||||
}
|
||||
|
||||
if ('stroke' in node && node.stroke) {
|
||||
const s = node.stroke
|
||||
if (Array.isArray(s.fill)) {
|
||||
for (const f of s.fill) {
|
||||
if (f.type === 'solid' && isVariableRef(f.color)) refs.add(f.color)
|
||||
}
|
||||
}
|
||||
if (isVariableRef(s.thickness)) refs.add(s.thickness as unknown as string)
|
||||
}
|
||||
|
||||
if ('children' in node && node.children) {
|
||||
for (const child of node.children) {
|
||||
collectRefs(child, refs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CATEGORY_KEYWORDS: Record<ComponentCategory, string[]> = {
|
||||
buttons: ['button', 'btn', 'cta'],
|
||||
inputs: ['input', 'text field', 'textarea', 'checkbox', 'radio', 'toggle', 'switch', 'select', 'form'],
|
||||
cards: ['card', 'tile'],
|
||||
navigation: ['nav', 'navbar', 'tab', 'breadcrumb', 'menu', 'sidebar'],
|
||||
layout: ['divider', 'separator', 'spacer', 'container', 'grid'],
|
||||
feedback: ['alert', 'banner', 'toast', 'badge', 'tag', 'chip', 'avatar', 'tooltip'],
|
||||
'data-display': ['table', 'list', 'stat', 'chart', 'progress'],
|
||||
other: [],
|
||||
}
|
||||
|
||||
function inferCategory(name: string): ComponentCategory {
|
||||
const lower = name.toLowerCase()
|
||||
for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
|
||||
if (category === 'other') continue
|
||||
for (const kw of keywords) {
|
||||
if (lower.includes(kw)) return category as ComponentCategory
|
||||
}
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function inferTags(name: string): string[] {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.split(/[\s-]+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
26
src/uikit/kits/default-kit-meta.ts
Normal file
26
src/uikit/kits/default-kit-meta.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ComponentCategory } from '@/types/uikit'
|
||||
|
||||
export const DEFAULT_KIT_META: Record<
|
||||
string,
|
||||
{ category: ComponentCategory; tags: string[] }
|
||||
> = {
|
||||
'uikit-btn-primary': { category: 'buttons', tags: ['button', 'primary', 'cta', 'action'] },
|
||||
'uikit-btn-secondary': { category: 'buttons', tags: ['button', 'secondary', 'outline'] },
|
||||
'uikit-btn-ghost': { category: 'buttons', tags: ['button', 'ghost', 'text', 'link'] },
|
||||
'uikit-btn-destructive': { category: 'buttons', tags: ['button', 'destructive', 'danger', 'delete'] },
|
||||
'uikit-input-text': { category: 'inputs', tags: ['input', 'text', 'field', 'form'] },
|
||||
'uikit-input-textarea': { category: 'inputs', tags: ['textarea', 'multiline', 'text', 'form'] },
|
||||
'uikit-input-checkbox': { category: 'inputs', tags: ['checkbox', 'check', 'toggle', 'form'] },
|
||||
'uikit-input-toggle': { category: 'inputs', tags: ['toggle', 'switch', 'on', 'off'] },
|
||||
'uikit-input-radio': { category: 'inputs', tags: ['radio', 'option', 'select', 'form'] },
|
||||
'uikit-card-basic': { category: 'cards', tags: ['card', 'container', 'surface', 'basic'] },
|
||||
'uikit-card-stats': { category: 'cards', tags: ['card', 'stats', 'metric', 'dashboard', 'kpi'] },
|
||||
'uikit-card-image': { category: 'cards', tags: ['card', 'image', 'media', 'thumbnail'] },
|
||||
'uikit-nav-bar': { category: 'navigation', tags: ['navbar', 'header', 'navigation', 'top'] },
|
||||
'uikit-tab-bar': { category: 'navigation', tags: ['tab', 'tabs', 'navigation', 'segment'] },
|
||||
'uikit-breadcrumb': { category: 'navigation', tags: ['breadcrumb', 'navigation', 'path'] },
|
||||
'uikit-alert-banner': { category: 'feedback', tags: ['alert', 'banner', 'notification', 'info'] },
|
||||
'uikit-badge': { category: 'feedback', tags: ['badge', 'tag', 'chip', 'label', 'status'] },
|
||||
'uikit-avatar': { category: 'feedback', tags: ['avatar', 'user', 'profile', 'icon'] },
|
||||
'uikit-divider': { category: 'layout', tags: ['divider', 'separator', 'line', 'hr'] },
|
||||
}
|
||||
763
src/uikit/kits/default-kit.ts
Normal file
763
src/uikit/kits/default-kit.ts
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
import type { PenDocument, PenNode } from '@/types/pen'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buttons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const btnPrimary: PenNode = {
|
||||
id: 'uikit-btn-primary',
|
||||
type: 'frame',
|
||||
name: 'Primary Button',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: [0, 20, 0, 20],
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-btn-primary-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Button',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const btnSecondary: PenNode = {
|
||||
id: 'uikit-btn-secondary',
|
||||
type: 'frame',
|
||||
name: 'Secondary Button',
|
||||
reusable: true,
|
||||
x: 140,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: [0, 20, 0, 20],
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-btn-secondary-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Button',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#374151' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const btnGhost: PenNode = {
|
||||
id: 'uikit-btn-ghost',
|
||||
type: 'frame',
|
||||
name: 'Ghost Button',
|
||||
reusable: true,
|
||||
x: 280,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: [0, 20, 0, 20],
|
||||
cornerRadius: 8,
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-btn-ghost-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Button',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const btnDestructive: PenNode = {
|
||||
id: 'uikit-btn-destructive',
|
||||
type: 'frame',
|
||||
name: 'Destructive Button',
|
||||
reusable: true,
|
||||
x: 420,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: [0, 20, 0, 20],
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#DC2626' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-btn-destructive-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Delete',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const inputText: PenNode = {
|
||||
id: 'uikit-input-text',
|
||||
type: 'frame',
|
||||
name: 'Text Input',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 60,
|
||||
width: 240,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
alignItems: 'center',
|
||||
padding: [0, 12, 0, 12],
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-input-text-placeholder',
|
||||
type: 'text',
|
||||
name: 'Placeholder',
|
||||
content: 'Enter text...',
|
||||
fontSize: 14,
|
||||
fill: [{ type: 'solid', color: '#9CA3AF' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputTextarea: PenNode = {
|
||||
id: 'uikit-input-textarea',
|
||||
type: 'frame',
|
||||
name: 'Textarea',
|
||||
reusable: true,
|
||||
x: 260,
|
||||
y: 60,
|
||||
width: 240,
|
||||
height: 96,
|
||||
layout: 'vertical',
|
||||
padding: 12,
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#D1D5DB' }] },
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-input-textarea-placeholder',
|
||||
type: 'text',
|
||||
name: 'Placeholder',
|
||||
content: 'Enter description...',
|
||||
fontSize: 14,
|
||||
fill: [{ type: 'solid', color: '#9CA3AF' }],
|
||||
width: 'fill_container',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputCheckbox: PenNode = {
|
||||
id: 'uikit-input-checkbox',
|
||||
type: 'frame',
|
||||
name: 'Checkbox',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 176,
|
||||
width: 160,
|
||||
height: 24,
|
||||
layout: 'horizontal',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-input-checkbox-box',
|
||||
type: 'rectangle',
|
||||
name: 'Box',
|
||||
width: 18,
|
||||
height: 18,
|
||||
cornerRadius: 4,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#2563EB' }] },
|
||||
},
|
||||
{
|
||||
id: 'uikit-input-checkbox-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Checkbox label',
|
||||
fontSize: 14,
|
||||
fill: [{ type: 'solid', color: '#374151' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputToggle: PenNode = {
|
||||
id: 'uikit-input-toggle',
|
||||
type: 'frame',
|
||||
name: 'Toggle Switch',
|
||||
reusable: true,
|
||||
x: 180,
|
||||
y: 176,
|
||||
width: 44,
|
||||
height: 24,
|
||||
cornerRadius: 12,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-input-toggle-thumb',
|
||||
type: 'ellipse',
|
||||
name: 'Thumb',
|
||||
x: 22,
|
||||
y: 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const inputRadio: PenNode = {
|
||||
id: 'uikit-input-radio',
|
||||
type: 'frame',
|
||||
name: 'Radio Button',
|
||||
reusable: true,
|
||||
x: 240,
|
||||
y: 176,
|
||||
width: 160,
|
||||
height: 24,
|
||||
layout: 'horizontal',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-input-radio-circle',
|
||||
type: 'ellipse',
|
||||
name: 'Circle',
|
||||
width: 18,
|
||||
height: 18,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 2, fill: [{ type: 'solid', color: '#2563EB' }] },
|
||||
},
|
||||
{
|
||||
id: 'uikit-input-radio-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Radio label',
|
||||
fontSize: 14,
|
||||
fill: [{ type: 'solid', color: '#374151' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const cardBasic: PenNode = {
|
||||
id: 'uikit-card-basic',
|
||||
type: 'frame',
|
||||
name: 'Basic Card',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 220,
|
||||
width: 280,
|
||||
height: 160,
|
||||
layout: 'vertical',
|
||||
gap: 8,
|
||||
padding: 20,
|
||||
cornerRadius: 12,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
|
||||
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-card-basic-title',
|
||||
type: 'text',
|
||||
name: 'Title',
|
||||
content: 'Card Title',
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#111827' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-card-basic-desc',
|
||||
type: 'text',
|
||||
name: 'Description',
|
||||
content: 'Card description goes here with some supporting text.',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
width: 'fill_container',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const cardStats: PenNode = {
|
||||
id: 'uikit-card-stats',
|
||||
type: 'frame',
|
||||
name: 'Stats Card',
|
||||
reusable: true,
|
||||
x: 300,
|
||||
y: 220,
|
||||
width: 200,
|
||||
height: 120,
|
||||
layout: 'vertical',
|
||||
gap: 4,
|
||||
padding: 20,
|
||||
cornerRadius: 12,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
|
||||
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-card-stats-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Total Revenue',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-card-stats-value',
|
||||
type: 'text',
|
||||
name: 'Value',
|
||||
content: '$45,231',
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
fill: [{ type: 'solid', color: '#111827' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-card-stats-change',
|
||||
type: 'text',
|
||||
name: 'Change',
|
||||
content: '+20.1% from last month',
|
||||
fontSize: 12,
|
||||
fill: [{ type: 'solid', color: '#16A34A' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const cardImage: PenNode = {
|
||||
id: 'uikit-card-image',
|
||||
type: 'frame',
|
||||
name: 'Image Card',
|
||||
reusable: true,
|
||||
x: 520,
|
||||
y: 220,
|
||||
width: 280,
|
||||
height: 240,
|
||||
layout: 'vertical',
|
||||
cornerRadius: 12,
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
|
||||
effects: [{ type: 'shadow', offsetX: 0, offsetY: 1, blur: 3, spread: 0, color: 'rgba(0,0,0,0.1)' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-card-image-placeholder',
|
||||
type: 'rectangle',
|
||||
name: 'Image',
|
||||
width: 'fill_container',
|
||||
height: 140,
|
||||
fill: [{ type: 'solid', color: '#F3F4F6' }],
|
||||
cornerRadius: [12, 12, 0, 0],
|
||||
},
|
||||
{
|
||||
id: 'uikit-card-image-body',
|
||||
type: 'frame',
|
||||
name: 'Body',
|
||||
width: 'fill_container',
|
||||
layout: 'vertical',
|
||||
gap: 4,
|
||||
padding: [12, 16, 16, 16],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-card-image-title',
|
||||
type: 'text',
|
||||
name: 'Title',
|
||||
content: 'Card Title',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#111827' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-card-image-desc',
|
||||
type: 'text',
|
||||
name: 'Description',
|
||||
content: 'Brief description text.',
|
||||
fontSize: 13,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const navbar: PenNode = {
|
||||
id: 'uikit-nav-bar',
|
||||
type: 'frame',
|
||||
name: 'Navbar',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 480,
|
||||
width: 800,
|
||||
height: 56,
|
||||
layout: 'horizontal',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space_between',
|
||||
padding: [0, 24, 0, 24],
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-nav-bar-brand',
|
||||
type: 'text',
|
||||
name: 'Brand',
|
||||
content: 'Brand',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
fill: [{ type: 'solid', color: '#111827' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-nav-bar-links',
|
||||
type: 'frame',
|
||||
name: 'Links',
|
||||
layout: 'horizontal',
|
||||
gap: 24,
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-nav-bar-link-1',
|
||||
type: 'text',
|
||||
name: 'Link',
|
||||
content: 'Home',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#374151' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-nav-bar-link-2',
|
||||
type: 'text',
|
||||
name: 'Link',
|
||||
content: 'Products',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-nav-bar-link-3',
|
||||
type: 'text',
|
||||
name: 'Link',
|
||||
content: 'About',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const tabBar: PenNode = {
|
||||
id: 'uikit-tab-bar',
|
||||
type: 'frame',
|
||||
name: 'Tab Bar',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 556,
|
||||
width: 400,
|
||||
height: 40,
|
||||
layout: 'horizontal',
|
||||
gap: 0,
|
||||
alignItems: 'center',
|
||||
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#E5E7EB' }] },
|
||||
cornerRadius: 8,
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-1',
|
||||
type: 'frame',
|
||||
name: 'Tab Active',
|
||||
height: 'fill_container',
|
||||
width: 'fill_container',
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fill: [{ type: 'solid', color: '#EFF6FF' }],
|
||||
cornerRadius: [8, 0, 0, 8],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-1-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Tab 1',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-2',
|
||||
type: 'frame',
|
||||
name: 'Tab',
|
||||
height: 'fill_container',
|
||||
width: 'fill_container',
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-2-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Tab 2',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-3',
|
||||
type: 'frame',
|
||||
name: 'Tab',
|
||||
height: 'fill_container',
|
||||
width: 'fill_container',
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
cornerRadius: [0, 8, 8, 0],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-tab-bar-tab-3-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Tab 3',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const breadcrumb: PenNode = {
|
||||
id: 'uikit-breadcrumb',
|
||||
type: 'frame',
|
||||
name: 'Breadcrumb',
|
||||
reusable: true,
|
||||
x: 420,
|
||||
y: 556,
|
||||
width: 280,
|
||||
height: 32,
|
||||
layout: 'horizontal',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-breadcrumb-home',
|
||||
type: 'text',
|
||||
name: 'Home',
|
||||
content: 'Home',
|
||||
fontSize: 13,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-breadcrumb-sep-1',
|
||||
type: 'text',
|
||||
name: 'Separator',
|
||||
content: '/',
|
||||
fontSize: 13,
|
||||
fill: [{ type: 'solid', color: '#D1D5DB' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-breadcrumb-section',
|
||||
type: 'text',
|
||||
name: 'Section',
|
||||
content: 'Section',
|
||||
fontSize: 13,
|
||||
fill: [{ type: 'solid', color: '#6B7280' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-breadcrumb-sep-2',
|
||||
type: 'text',
|
||||
name: 'Separator',
|
||||
content: '/',
|
||||
fontSize: 13,
|
||||
fill: [{ type: 'solid', color: '#D1D5DB' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-breadcrumb-current',
|
||||
type: 'text',
|
||||
name: 'Current',
|
||||
content: 'Current Page',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#111827' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const alertBanner: PenNode = {
|
||||
id: 'uikit-alert-banner',
|
||||
type: 'frame',
|
||||
name: 'Alert Banner',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 616,
|
||||
width: 400,
|
||||
height: 56,
|
||||
layout: 'horizontal',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: [0, 16, 0, 16],
|
||||
cornerRadius: 8,
|
||||
fill: [{ type: 'solid', color: '#EFF6FF' }],
|
||||
stroke: { thickness: 1, fill: [{ type: 'solid', color: '#BFDBFE' }] },
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-alert-banner-icon',
|
||||
type: 'ellipse',
|
||||
name: 'Icon',
|
||||
width: 8,
|
||||
height: 8,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
},
|
||||
{
|
||||
id: 'uikit-alert-banner-text',
|
||||
type: 'text',
|
||||
name: 'Message',
|
||||
content: 'This is an informational alert message.',
|
||||
fontSize: 14,
|
||||
fill: [{ type: 'solid', color: '#1E40AF' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const badge: PenNode = {
|
||||
id: 'uikit-badge',
|
||||
type: 'frame',
|
||||
name: 'Badge',
|
||||
reusable: true,
|
||||
x: 420,
|
||||
y: 616,
|
||||
width: 64,
|
||||
height: 24,
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: [0, 10, 0, 10],
|
||||
cornerRadius: 12,
|
||||
fill: [{ type: 'solid', color: '#DBEAFE' }],
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-badge-label',
|
||||
type: 'text',
|
||||
name: 'Label',
|
||||
content: 'Badge',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
fill: [{ type: 'solid', color: '#1D4ED8' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const avatar: PenNode = {
|
||||
id: 'uikit-avatar',
|
||||
type: 'frame',
|
||||
name: 'Avatar',
|
||||
reusable: true,
|
||||
x: 504,
|
||||
y: 616,
|
||||
width: 40,
|
||||
height: 40,
|
||||
cornerRadius: 20,
|
||||
fill: [{ type: 'solid', color: '#DBEAFE' }],
|
||||
layout: 'horizontal',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
children: [
|
||||
{
|
||||
id: 'uikit-avatar-initials',
|
||||
type: 'text',
|
||||
name: 'Initials',
|
||||
content: 'JD',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
fill: [{ type: 'solid', color: '#2563EB' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const divider: PenNode = {
|
||||
id: 'uikit-divider',
|
||||
type: 'frame',
|
||||
name: 'Divider',
|
||||
reusable: true,
|
||||
x: 0,
|
||||
y: 692,
|
||||
width: 400,
|
||||
height: 1,
|
||||
fill: [{ type: 'solid', color: '#E5E7EB' }],
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_KIT_DOCUMENT: PenDocument = {
|
||||
version: '1.0.0',
|
||||
name: 'Default UIKit',
|
||||
children: [
|
||||
btnPrimary,
|
||||
btnSecondary,
|
||||
btnGhost,
|
||||
btnDestructive,
|
||||
inputText,
|
||||
inputTextarea,
|
||||
inputCheckbox,
|
||||
inputToggle,
|
||||
inputRadio,
|
||||
cardBasic,
|
||||
cardStats,
|
||||
cardImage,
|
||||
navbar,
|
||||
tabBar,
|
||||
breadcrumb,
|
||||
alertBanner,
|
||||
badge,
|
||||
avatar,
|
||||
divider,
|
||||
],
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Canvas } from 'fabric'
|
||||
import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory'
|
||||
|
||||
function downloadFile(url: string, filename: string) {
|
||||
const link = document.createElement('a')
|
||||
|
|
@ -43,6 +44,88 @@ export function exportToPNG(canvas: Canvas, options?: PNGExportOptions) {
|
|||
downloadFile(dataURL, filename)
|
||||
}
|
||||
|
||||
export type RasterFormat = 'png' | 'jpeg' | 'webp'
|
||||
|
||||
export interface RasterExportOptions {
|
||||
format?: RasterFormat
|
||||
multiplier?: number
|
||||
filename?: string
|
||||
selectedOnly?: boolean
|
||||
}
|
||||
|
||||
export function exportToRaster(canvas: Canvas, options?: RasterExportOptions) {
|
||||
const format = options?.format ?? 'png'
|
||||
const multiplier = options?.multiplier ?? 2
|
||||
const filename = options?.filename ?? `design.${format === 'jpeg' ? 'jpg' : format}`
|
||||
const quality = format === 'png' ? 1 : 0.92
|
||||
|
||||
const exportOpts = { format, multiplier, quality } as Parameters<Canvas['toDataURL']>[0]
|
||||
|
||||
if (options?.selectedOnly) {
|
||||
const active = canvas.getActiveObject()
|
||||
if (active) {
|
||||
const dataURL = active.toDataURL(exportOpts)
|
||||
downloadFile(dataURL, filename)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const dataURL = canvas.toDataURL(exportOpts)
|
||||
downloadFile(dataURL, filename)
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a layer (node + all descendants) as a raster image.
|
||||
* Nodes are flattened on canvas as individual Fabric objects. We render them
|
||||
* onto a fresh offscreen canvas to avoid viewport transform issues.
|
||||
*/
|
||||
export function exportLayerToRaster(
|
||||
canvas: Canvas,
|
||||
nodeId: string,
|
||||
descendantIds: Set<string>,
|
||||
options?: Omit<RasterExportOptions, 'selectedOnly'>,
|
||||
) {
|
||||
const format = options?.format ?? 'png'
|
||||
const multiplier = options?.multiplier ?? 1
|
||||
const filename = options?.filename ?? `design.${format === 'jpeg' ? 'jpg' : format}`
|
||||
const quality = format === 'png' ? 1 : 0.92
|
||||
|
||||
const allIds = new Set(descendantIds)
|
||||
allIds.add(nodeId)
|
||||
|
||||
const allObjects = canvas.getObjects() as FabricObjectWithPenId[]
|
||||
|
||||
// Find the root node's Fabric object to determine crop bounds
|
||||
const rootObj = allObjects.find((obj) => obj.penNodeId === nodeId)
|
||||
if (!rootObj) return
|
||||
|
||||
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)
|
||||
|
||||
// Collect layer objects in render order
|
||||
const layerObjects = allObjects.filter(
|
||||
(obj) => obj.penNodeId && allIds.has(obj.penNodeId),
|
||||
)
|
||||
|
||||
// Render onto a fresh offscreen canvas — no viewport transform interference
|
||||
const offscreen = document.createElement('canvas')
|
||||
offscreen.width = Math.ceil(w * multiplier)
|
||||
offscreen.height = Math.ceil(h * multiplier)
|
||||
const ctx = offscreen.getContext('2d')!
|
||||
ctx.scale(multiplier, multiplier)
|
||||
ctx.translate(-originX, -originY)
|
||||
|
||||
for (const obj of layerObjects) {
|
||||
obj.render(ctx)
|
||||
}
|
||||
|
||||
const mimeType = format === 'jpeg' ? 'image/jpeg' : format === 'webp' ? 'image/webp' : 'image/png'
|
||||
const dataURL = offscreen.toDataURL(mimeType, quality)
|
||||
downloadFile(dataURL, filename)
|
||||
}
|
||||
|
||||
export function exportToSVG(canvas: Canvas, options?: SVGExportOptions) {
|
||||
const filename = options?.filename ?? 'design.svg'
|
||||
|
||||
|
|
|
|||
|
|
@ -34,11 +34,11 @@ export async function saveDocumentAs(
|
|||
showSaveFilePicker: (opts: unknown) => Promise<FileSystemFileHandle>
|
||||
}
|
||||
).showSaveFilePicker({
|
||||
suggestedName: suggestedName || 'untitled.pen',
|
||||
suggestedName: suggestedName || 'untitled.op',
|
||||
types: [
|
||||
{
|
||||
description: 'Pen Design File',
|
||||
accept: { 'application/json': ['.pen'] },
|
||||
description: 'OpenPencil File',
|
||||
accept: { 'application/json': ['.op'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -66,8 +66,8 @@ export async function openDocumentFS(): Promise<{
|
|||
).showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'Pen Design File',
|
||||
accept: { 'application/json': ['.pen', '.json'] },
|
||||
description: 'OpenPencil File',
|
||||
accept: { 'application/json': ['.op', '.pen', '.json'] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
@ -108,7 +108,7 @@ export function openDocument(): Promise<{
|
|||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.pen,.json'
|
||||
input.accept = '.op,.pen,.json'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export function cloneNodesWithNewIds(
|
|||
): PenNode[] {
|
||||
return structuredClone(nodes).map((node) => {
|
||||
const withNewId = reassignIds(node)
|
||||
// Cloned nodes should not retain reusable component status
|
||||
if ('reusable' in withNewId) {
|
||||
delete (withNewId as unknown as Record<string, unknown>).reusable
|
||||
}
|
||||
if (offset !== 0) {
|
||||
withNewId.x = (withNewId.x ?? 0) + offset
|
||||
withNewId.y = (withNewId.y ?? 0) + offset
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue