From ffbd2be112a2c2cc419a339c30f3348a93523d1a Mon Sep 17 00:00:00 2001 From: Kayshen Xu Date: Mon, 2 Mar 2026 22:26:09 +0800 Subject: [PATCH] V0.1.1 (#17) * chore(electron): update mac build scripts for improved artifact handling - Modified the `electron:build:mac-arm64` script to rename the generated YAML file for better clarity. - Adjusted the `electron:build:mac-both` script to run builds sequentially without file renaming logic, ensuring consistent output. * chore(electron): enable notarization for macOS builds and update build workflow secrets - Added notarization support in `electron-builder.yml` for enhanced security. - Updated GitHub Actions workflow to include necessary Apple credentials for notarization. * chore(electron): add additional secrets for macOS notarization in build workflow - Included CSC_LINK and CSC_KEY_PASSWORD in the GitHub Actions workflow to support code signing for macOS builds. * feat(types): add ImageFitMode type and objectFit to ImageNode Support fill/fit/crop/tile image scaling modes, matching Figma's image fill behavior. Default is 'fill' (cover) for backward compat. * feat(canvas): render images per fill mode with native crop Add computeImageTransform helper supporting fill/fit/crop/tile modes. Fill/crop uses FabricImage native cropX/cropY instead of clipPath to avoid conflict with parent frame clipping. Tile mode creates a Rect with Pattern fill. Detect mode changes via __needsRecreation flag for object recreation when switching between tile and non-tile modes. * feat(panels): add image fit mode dropdown to property panel New ImageSection component with Fill/Fit/Crop/Tile dropdown for image nodes. Wired into PropertyPanel between icon and appearance sections. * feat(figma): preserve image scale mode from Figma import Map Figma imageScaleMode (FIT/FILL/TILE) to objectFit property on imported ImageNodes so fill mode is preserved across import. * fix(canvas): fix zoom-to-fit bounds inflated by clipped children computeDocBounds was recursing into frame children, inflating the bounding box beyond visible frame bounds. Now only recurses into groups. Also use double-RAF in Figma import for reliable timing. * feat(figma): implement Figma clipboard paste functionality - Added a new hook, useFigmaPaste, to handle pasting Figma clipboard data into the canvas. - Integrated clipboard data extraction and processing to convert Figma nodes into PenNodes. - Enhanced keyboard shortcuts to attempt reading Figma data from the system clipboard as a fallback. - Introduced utility functions for decoding and processing Figma clipboard HTML data. - Updated editor layout to utilize the new Figma paste functionality. * feat(figma): implement Figma clipboard support for pasting nodes - Added a new hook, `useFigmaPaste`, to handle Figma clipboard data extraction and processing. - Integrated Figma clipboard support into the editor layout and keyboard shortcuts for seamless pasting. - Updated README to reflect changes in file format from `.pen` to `.op`. - Refactored AI service methods to route to appropriate provider SDK based on the `provider` field, enhancing flexibility in AI interactions. * fix(figma): preserve imported node order and disable openpencil auto layout Prevent imported/generated nodes from being prepended in auto-layout containers, which could reverse visual order during progressive insertion. Hide the unfinished OpenPencil auto-layout path from the import dialog to avoid selecting a mode that is not ready yet. * fix(ai): enforce explicit provider and model routing Pass selected provider and model through design generation, orchestration, sub-agent, and validation flows. Disable provider/model fallback and remove model retry-without-selection behavior so requests fail fast instead of silently routing to Claude. * chore(package): bump version to 0.1.1 --------- Co-authored-by: Fini --- .github/workflows/build-electron.yml | 5 + README.md | 3 +- electron-builder.yml | 1 + package.json | 6 +- server/api/ai/chat.ts | 203 ++----------- server/api/ai/generate.ts | 72 ++--- server/api/ai/validate.ts | 273 ++++++++++-------- src/canvas/canvas-object-factory.ts | 168 +++++++++-- src/canvas/canvas-object-sync.ts | 47 +-- src/canvas/use-canvas-sync.ts | 15 +- src/canvas/use-fabric-canvas.ts | 5 +- src/components/editor/editor-layout.tsx | 4 + src/components/panels/ai-chat-handlers.ts | 11 +- src/components/panels/image-section.tsx | 29 ++ src/components/panels/property-panel.tsx | 15 +- src/components/shared/figma-import-dialog.tsx | 12 +- src/hooks/use-figma-paste.ts | 191 ++++++++++++ src/hooks/use-keyboard-shortcuts.ts | 7 + src/services/ai/ai-service.ts | 4 +- src/services/ai/ai-types.ts | 4 + src/services/ai/design-canvas-ops.ts | 9 +- src/services/ai/design-generator.ts | 5 +- src/services/ai/design-validation.ts | 30 +- src/services/ai/orchestrator-sub-agent.ts | 5 +- src/services/ai/orchestrator.ts | 10 +- src/services/figma/figma-clipboard.ts | 179 ++++++++++++ src/services/figma/figma-node-mapper.ts | 127 +++++++- src/types/pen.ts | 3 + 28 files changed, 1009 insertions(+), 434 deletions(-) create mode 100644 src/components/panels/image-section.tsx create mode 100644 src/hooks/use-figma-paste.ts create mode 100644 src/services/figma/figma-clipboard.ts diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index ff218cb4a..cfbaa2377 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -61,6 +61,11 @@ jobs: run: npx electron-builder --config electron-builder.yml ${{ matrix.build_args }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - name: Rename arm64 update metadata if: matrix.platform == 'mac-arm64' diff --git a/README.md b/README.md index ccae59bac..7bc877859 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,6 @@ OpenPencil integrates with multiple AI coding agents to generate full-page desig | Agent | Setup | | --- | --- | -| **Anthropic API** | Set `ANTHROPIC_API_KEY` in `.env` | | **Claude Code** | No config — uses Claude Agent SDK with local OAuth | | **Codex CLI** | Connect in Agent Settings (`Cmd+,`) | | **OpenCode** | Connect in Agent Settings (`Cmd+,`) | @@ -109,7 +108,7 @@ OpenPencil integrates with multiple AI coding agents to generate full-page desig | **Desktop** | Electron 35 | | **AI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK | | **Runtime** | Bun · Vite 7 | -| **File format** | `.pen` — JSON-based, human-readable, Git-friendly | +| **File format** | `.op` — JSON-based, human-readable, Git-friendly | ## Project Structure diff --git a/electron-builder.yml b/electron-builder.yml index 8e57b4c66..3c219a689 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -27,6 +27,7 @@ mac: - zip hardenedRuntime: true gatekeeperAssess: false + notarize: true dmg: title: "${productName} ${version}" diff --git a/package.json b/package.json index 784d5caa1..4a7668163 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openpencil", - "version": "0.1.0", + "version": "0.1.1", "description": "Open-source vector design tool with Design-as-Code philosophy", "author": { "name": "ZSeven-W", @@ -22,10 +22,10 @@ "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", - "electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --arm64", + "electron:build:mac-arm64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --arm64 && if [ -f dist-electron/latest-mac.yml ]; then mv dist-electron/latest-mac.yml dist-electron/latest-mac-arm64.yml; fi", "electron:build:mac-x64": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --x64", "electron:build:mac-universal": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml --mac --arm64 --x64", - "electron:build:mac-both": "bun run electron:build:mac-arm64 && if [ -f dist-electron/latest-mac.yml ]; then mv dist-electron/latest-mac.yml dist-electron/latest-mac-arm64.yml; fi && bun run electron:build:mac-x64" + "electron:build:mac-both": "bun run electron:build:mac-arm64 && bun run electron:build:mac-x64" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.47", diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts index 3f8ae530d..83704cdfc 100644 --- a/server/api/ai/chat.ts +++ b/server/api/ai/chat.ts @@ -19,7 +19,7 @@ interface ChatBody { system: string messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }> model?: string - provider?: string + provider?: 'anthropic' | 'openai' | 'opencode' thinkingMode?: 'adaptive' | 'disabled' | 'enabled' thinkingBudgetTokens?: number effort?: 'low' | 'medium' | 'high' | 'max' @@ -36,10 +36,6 @@ async function readDebugTail(path?: string, maxLines = 40): Promise { const body = await readBody(event) @@ -72,6 +68,18 @@ export default defineEventHandler(async (event) => { setResponseHeaders(event, { 'Content-Type': 'application/json' }) return { error: 'Missing required fields: system, messages' } } + if (!body.provider) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing provider. Provider fallback is disabled.' } + } + if (!body.model?.trim()) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing model. Model fallback is disabled.' } + } + if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode') { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } + } setResponseHeaders(event, { 'Content-Type': 'text/event-stream', @@ -79,45 +87,13 @@ 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 { - return await streamViaAnthropicSDK(apiKey, body, body.model) - } catch { - // SDK not installed or failed — fall back to Agent SDK - } - } - return streamViaAgentSDK(body, body.model) + if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model) + if (body.provider === 'opencode') return streamViaOpenCode(body, body.model) + return streamViaCodex(body, body.model) }) // Keep-alive ping interval (ms) — prevents client timeout while waiting for API TTFT const KEEPALIVE_INTERVAL_MS = 15_000 -// Max time to wait for the first SDK event (text/thinking/error). -// If the API provider doesn't respond within this window, abort and surface -// a clear error instead of letting the client wait minutes for a timeout. -const API_CONNECT_TIMEOUT_MS = 30_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 } @@ -175,93 +151,6 @@ function stripNoToolsRestriction(systemPrompt: string): string { .replace(/\n{3,}/g, '\n\n') } -/** Build Anthropic SDK multimodal messages from ChatBody messages */ -function buildAnthropicMessages(body: ChatBody): Array<{ role: string; content: unknown }> { - return body.messages.map((m) => { - const attachments = m.attachments ?? [] - if (attachments.length === 0) { - return { role: m.role, content: m.content } - } - const content: Array> = [ - ...attachments.map((a) => ({ - type: 'image', - source: { type: 'base64', media_type: a.mediaType, data: a.data }, - })), - { type: 'text', text: m.content || 'Analyze these images.' }, - ] - return { role: m.role, content } - }) -} - -/** 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') - // Disable automatic retries so auth/balance errors (429) surface immediately - // instead of waiting through exponential backoff retry cycles. - const client = new Anthropic({ apiKey, maxRetries: 0 }) - - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - const send = (type: string, content: string) => { - try { - controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type, content })}\n\n`)) - } catch { /* stream already closed */ } - } - const pingTimer = setInterval(() => send('ping', ''), KEEPALIVE_INTERVAL_MS) - - // Abort if the API provider doesn't produce any event within the timeout. - // Catches slow proxies, invalid keys on slow endpoints, etc. - const connectAbort = new AbortController() - let gotSdkEvent = false - const connectTimer = setTimeout(() => { - if (!gotSdkEvent) connectAbort.abort() - }, API_CONNECT_TIMEOUT_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: buildAnthropicMessages(body) as any, - ...(body.effort ? { effort: body.effort } : {}), - ...(thinking ? { thinking } : {}), - }, { signal: connectAbort.signal }) - - for await (const ev of messageStream) { - if (!gotSdkEvent) { - gotSdkEvent = true - clearTimeout(connectTimer) - } - if (ev.type === 'content_block_delta') { - if (ev.delta.type === 'text_delta') { - clearInterval(pingTimer) - send('text', ev.delta.text) - } else if (ev.delta.type === 'thinking_delta') { - send('thinking', ev.delta.thinking) - } - } - } - - send('done', '') - } catch (error) { - clearTimeout(connectTimer) - const content = connectAbort.signal.aborted && !gotSdkEvent - ? 'API connection timed out (30s). Check your API key and network configuration.' - : error instanceof Error ? error.message : 'Unknown error' - send('error', content) - } finally { - clearTimeout(connectTimer) - clearInterval(pingTimer) - controller.close() - } - }, - }) - - return new Response(stream) -} - /** Stream via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */ function streamViaAgentSDK(body: ChatBody, model?: string) { const stream = new ReadableStream({ @@ -273,7 +162,6 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) } catch { /* stream already closed */ } }, KEEPALIVE_INTERVAL_MS) - let emittedText = false let debugFile: string | undefined let attachTempDir: string | undefined @@ -315,12 +203,12 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { // only emit the final result text. This avoids streaming intermediate // tool-use preamble like "I need to read the file first". if (hasImageAttachments) { - const runImageQuery = async (modelOverride?: string): Promise => { + const runImageQuery = async (): Promise => { const q = query({ prompt, options: { systemPrompt: effectiveSystemPrompt, - ...(modelOverride ? { model: modelOverride } : {}), + ...(model ? { model } : {}), maxTurns: 3, plugins: [], permissionMode: 'plan', @@ -343,9 +231,6 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { const errors = 'errors' in message ? (message.errors as string[]) : [] const resultText = 'result' in message ? String(message.result ?? '') : '' const errContent = errors.join('; ') || resultText || `Query ended with: ${message.subtype}` - if (modelOverride && shouldRetryClaudeWithoutModel(errContent)) { - throw new Error(errContent) - } throw new Error(errContent) } } @@ -355,33 +240,22 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { } } - let resultText: string - try { - resultText = await runImageQuery(model) - } catch (error) { - const raw = error instanceof Error ? error.message : String(error) - if (model && shouldRetryClaudeWithoutModel(raw)) { - resultText = await runImageQuery(undefined) - } else { - throw error - } - } + const resultText = await runImageQuery() clearInterval(pingTimer) if (resultText) { - emittedText = true controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: 'text', content: resultText })}\n\n`), ) } } else { // Normal text-only chat: stream partial messages as before - const runQuery = async (modelOverride?: string) => { + const runQuery = async () => { const q = query({ prompt, options: { systemPrompt: effectiveSystemPrompt, - ...(modelOverride ? { model: modelOverride } : {}), + ...(model ? { model } : {}), maxTurns: 1, includePartialMessages: true, tools: [], @@ -402,7 +276,6 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { const ev = message.event if (ev.type === 'content_block_delta') { if (ev.delta.type === 'text_delta') { - emittedText = true clearInterval(pingTimer) const data = JSON.stringify({ type: 'text', content: ev.delta.text }) controller.enqueue(encoder.encode(`data: ${data}\n\n`)) @@ -413,17 +286,14 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { } } } else if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) - if (message.subtype !== 'success' || isErrorResult) { - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}` - if (modelOverride && !emittedText && shouldRetryClaudeWithoutModel(content)) { - throw new Error(content) - } - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), - ) + const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) + if (message.subtype !== 'success' || isErrorResult) { + const errors = 'errors' in message ? (message.errors as string[]) : [] + const resultText = 'result' in message ? String(message.result ?? '') : '' + const content = errors.join('; ') || resultText || `Query ended with: ${message.subtype}` + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), + ) } } } @@ -432,16 +302,7 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { } } - try { - await runQuery(model) - } catch (error) { - const raw = error instanceof Error ? error.message : String(error) - if (model && !emittedText && shouldRetryClaudeWithoutModel(raw)) { - await runQuery(undefined) - } else { - throw error - } - } + await runQuery() } controller.enqueue( diff --git a/server/api/ai/generate.ts b/server/api/ai/generate.ts index 0995d927f..7f3ec7be7 100644 --- a/server/api/ai/generate.ts +++ b/server/api/ai/generate.ts @@ -10,20 +10,16 @@ interface GenerateBody { system: string message: string model?: string - provider?: string + provider?: 'anthropic' | 'openai' | 'opencode' thinkingMode?: 'adaptive' | 'disabled' | 'enabled' thinkingBudgetTokens?: number effort?: 'low' | 'medium' | 'high' | 'max' } -function shouldRetryClaudeWithoutModel(raw: string): boolean { - return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw) -} - /** * Non-streaming AI generation endpoint. - * Tries ANTHROPIC_API_KEY first (via Anthropic SDK); - * falls back to local Claude Code (via Agent SDK, uses OAuth login). + * Routes to the appropriate provider SDK based on the `provider` field. + * Requires explicit provider and model; no fallback routing. */ export default defineEventHandler(async (event) => { const body = await readBody(event) @@ -32,50 +28,30 @@ export default defineEventHandler(async (event) => { setResponseHeaders(event, { 'Content-Type': 'application/json' }) return { error: 'Missing required fields: system, message' } } + if (!body.provider) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing provider. Provider fallback is disabled.' } + } + if (!body.model?.trim()) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing model. Model fallback is disabled.' } + } - // Explicit provider routing + if (body.provider === 'anthropic') { + return generateViaAgentSDK(body, body.model) + } 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 { - return await generateViaAnthropicSDK(apiKey, body, body.model) - } catch { - // SDK not installed or failed — fall back to Agent SDK - } - } - return generateViaAgentSDK(body, body.model) + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } }) -/** Generate via Anthropic SDK */ -async function generateViaAnthropicSDK(apiKey: string, body: GenerateBody, model?: string) { - try { - const { default: Anthropic } = await import('@anthropic-ai/sdk') - const client = new Anthropic({ apiKey }) - const response = await client.messages.create({ - model: model || 'claude-sonnet-4-5-20250929', - max_tokens: 4096, - system: body.system, - messages: [{ role: 'user', content: body.message }], - }) - - const textBlock = response.content.find((b: { type: string }) => b.type === 'text') - return { text: textBlock && 'text' in textBlock ? textBlock.text : '' } - } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' - return { error: message } - } -} - /** Generate via Claude Agent SDK (uses local Claude Code OAuth login, no API key needed) */ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { - const runQuery = async (modelOverride?: string): Promise<{ text?: string; error?: string }> => { + const runQuery = async (): Promise<{ text?: string; error?: string }> => { const { query } = await import('@anthropic-ai/claude-agent-sdk') // Remove CLAUDECODE env to allow running from within a CC terminal @@ -88,7 +64,7 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise< prompt: body.message, options: { systemPrompt: body.system, - ...(modelOverride ? { model: modelOverride } : {}), + ...(model ? { model } : {}), maxTurns: 1, tools: [], plugins: [], @@ -120,21 +96,9 @@ async function generateViaAgentSDK(body: GenerateBody, model?: string): Promise< } try { - const first = await runQuery(model) - if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) { - return await runQuery(undefined) - } - return first + return await runQuery() } catch (error) { const message = error instanceof Error ? error.message : String(error) - if (model && shouldRetryClaudeWithoutModel(message)) { - try { - return await runQuery(undefined) - } catch (retryError) { - const retryMessage = retryError instanceof Error ? retryError.message : String(retryError) - return { error: retryMessage } - } - } return { error: message } } } diff --git a/server/api/ai/validate.ts b/server/api/ai/validate.ts index f6944a651..256042223 100644 --- a/server/api/ai/validate.ts +++ b/server/api/ai/validate.ts @@ -4,30 +4,26 @@ import { buildClaudeAgentEnv, getClaudeAgentDebugFilePath, } from '../../utils/resolve-claude-agent-env' -import { writeFile, unlink, mkdtemp } from 'node:fs/promises' +import { writeFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { runCodexExec } from '../../utils/codex-client' interface ValidateBody { system: string message: string imageBase64: string model?: string - provider?: string -} - -function shouldRetryClaudeWithoutModel(raw: string): boolean { - return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw) + provider?: 'anthropic' | 'openai' | 'opencode' } /** * Vision-based validation endpoint. * Accepts a base64 PNG screenshot and a text prompt, sends multimodal - * content blocks for analysis. + * content blocks for analysis via Agent SDK. * - * - Anthropic API key: uses SDK multimodal content blocks directly. - * - Agent SDK fallback: saves screenshot to temp file, asks Claude Code - * to read it via its built-in Read tool. + * Saves screenshot to temp file, asks Claude Code to read it via its + * built-in Read tool. */ export default defineEventHandler(async (event) => { const body = await readBody(event) @@ -37,63 +33,45 @@ export default defineEventHandler(async (event) => { return { error: 'Missing required fields: system, message, imageBase64' } } - // Try Anthropic SDK first (direct multimodal support) - const apiKey = process.env.ANTHROPIC_API_KEY - if (apiKey) { - try { - return await validateViaAnthropicSDK(apiKey, body, body.model) - } catch { - // Fall through to Agent SDK - } + if (!body.model?.trim()) { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + return { error: 'Missing model. Model fallback is disabled.' } } - // Fallback: Agent SDK — save screenshot to temp file, let Claude read it try { - return await validateViaAgentSDK(body, body.model) + if (body.provider === 'anthropic') { + return await validateViaAgentSDK(body, body.model) + } + if (body.provider === 'openai') { + return await validateViaCodex(body, body.model) + } + if (body.provider === 'opencode') { + return await validateViaOpenCode(body, body.model) + } + return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' return { error: message } } }) -async function validateViaAnthropicSDK( - apiKey: string, - body: ValidateBody, - model?: string, -): Promise<{ text: string; skipped?: boolean }> { - const { default: Anthropic } = await import('@anthropic-ai/sdk') - const client = new Anthropic({ apiKey }) - - // Strip data URL prefix if present - let base64Data = body.imageBase64 +function toImageBase64(data: string): string { const dataUrlPrefix = 'data:image/png;base64,' - if (base64Data.startsWith(dataUrlPrefix)) { - base64Data = base64Data.slice(dataUrlPrefix.length) + return data.startsWith(dataUrlPrefix) ? data.slice(dataUrlPrefix.length) : data +} + +async function withTempImageFile( + imageBase64: string, + run: (tempPath: string) => Promise, +): Promise { + const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')) + const tempPath = join(tempDir, 'screenshot.png') + try { + await writeFile(tempPath, Buffer.from(toImageBase64(imageBase64), 'base64')) + return await run(tempPath) + } finally { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) } - - const response = await client.messages.create({ - model: model || 'claude-sonnet-4-5-20250929', - max_tokens: 4096, - system: body.system, - messages: [ - { - role: 'user', - content: [ - { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: base64Data }, - }, - { - type: 'text', - text: body.message, - }, - ], - }, - ], - }) - - const textBlock = response.content.find((b: { type: string }) => b.type === 'text') - return { text: textBlock && 'text' in textBlock ? textBlock.text : '' } } /** @@ -104,19 +82,7 @@ async function validateViaAgentSDK( body: ValidateBody, model?: string, ): Promise<{ text: string; skipped?: boolean; error?: string }> { - // Save base64 image to temp file - let base64Data = body.imageBase64 - const dataUrlPrefix = 'data:image/png;base64,' - if (base64Data.startsWith(dataUrlPrefix)) { - base64Data = base64Data.slice(dataUrlPrefix.length) - } - - const tempDir = await mkdtemp(join(tmpdir(), 'openpencil-validate-')) - const tempPath = join(tempDir, 'screenshot.png') - - try { - await writeFile(tempPath, Buffer.from(base64Data, 'base64')) - + return await withTempImageFile(body.imageBase64, async (tempPath) => { const { query } = await import('@anthropic-ai/claude-agent-sdk') const env = buildClaudeAgentEnv() @@ -133,63 +99,128 @@ ${body.system} Output ONLY the JSON object, no markdown fences, no explanation.` - const runQuery = async (modelOverride?: string): Promise<{ text: string; skipped?: boolean; error?: string }> => { - const q = query({ - prompt, - options: { - ...(modelOverride ? { model: modelOverride } : {}), - maxTurns: 2, - tools: [], - plugins: [], - permissionMode: 'plan', - persistSession: false, - env, - ...(debugFile ? { debugFile } : {}), - ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), - }, - }) + const q = query({ + prompt, + options: { + ...(model ? { model } : {}), + maxTurns: 2, + tools: [], + plugins: [], + permissionMode: 'plan', + persistSession: false, + env, + ...(debugFile ? { debugFile } : {}), + ...(claudePath ? { pathToClaudeCodeExecutable: claudePath } : {}), + }, + }) - try { - for await (const message of q) { - if (message.type === 'result') { - const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) - if (message.subtype === 'success' && !isErrorResult) { - return { text: message.result } - } - const errors = 'errors' in message ? (message.errors as string[]) : [] - const resultText = 'result' in message ? String(message.result ?? '') : '' - return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' } + try { + for await (const message of q) { + if (message.type === 'result') { + const isErrorResult = 'is_error' in message && Boolean((message as { is_error?: boolean }).is_error) + if (message.subtype === 'success' && !isErrorResult) { + return { text: message.result } } - } - } finally { - q.close() - } - - return { text: '', skipped: true } - } - - try { - const first = await runQuery(model) - if (model && first.error && shouldRetryClaudeWithoutModel(first.error)) { - return await runQuery(undefined) - } - return first - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (model && shouldRetryClaudeWithoutModel(message)) { - try { - return await runQuery(undefined) - } catch (retryError) { - const retryMessage = retryError instanceof Error ? retryError.message : String(retryError) - return { error: retryMessage, text: '' } + const errors = 'errors' in message ? (message.errors as string[]) : [] + const resultText = 'result' in message ? String(message.result ?? '') : '' + return { error: errors.join('; ') || resultText || `Query ended with: ${message.subtype}`, text: '' } } } - return { error: message, text: '' } + } finally { + q.close() } + + return { text: '', skipped: true } + }) +} + +async function validateViaCodex( + body: ValidateBody, + model?: string, +): Promise<{ text: string; skipped?: boolean; error?: string }> { + return await withTempImageFile(body.imageBase64, async (tempPath) => { + const result = await runCodexExec( + `${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`, + { + model, + systemPrompt: body.system, + imageFiles: [tempPath], + }, + ) + if (result.error) { + return { text: '', error: result.error } + } + return { text: result.text ?? '' } + }) +} + +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) } +} + +async function validateViaOpenCode( + body: ValidateBody, + model?: string, +): Promise<{ text: string; skipped?: boolean; error?: string }> { + let ocServer: { close(): void } | undefined + try { + const { getOpencodeClient } = await import('../../utils/opencode-client') + const oc = await getOpencodeClient() + const ocClient: any = oc.client + ocServer = oc.server + + const { data: session, error: sessionError } = await ocClient.session.create({ + title: 'OpenPencil Validate', + }) + if (sessionError || !session) { + return { text: '', error: 'Failed to create OpenCode session' } + } + + await ocClient.session.prompt({ + sessionID: session.id, + noReply: true, + parts: [{ type: 'text', text: body.system }], + }) + + const parsed = parseOpenCodeModel(model) + if (!parsed) { + return { text: '', error: 'Invalid OpenCode model format. Expected "provider/model".' } + } + + const base64 = toImageBase64(body.imageBase64) + const promptPayload = { + sessionID: session.id, + model: parsed, + parts: [ + { type: 'image', url: `data:image/png;base64,${base64}` }, + { + type: 'text', + text: `${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.`, + }, + ], + } + + const { data: result, error: promptError } = await ocClient.session.prompt(promptPayload) + if (promptError) { + return { text: '', error: 'OpenCode validation failed' } + } + + 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 { text: '', error: message } } finally { - // Clean up temp file - try { - await unlink(tempPath) - } catch { /* ignore */ } + const { releaseOpencodeServer } = await import('../../utils/opencode-client') + releaseOpencodeServer(ocServer) } } diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index 0bb04f5f6..c84a7a2fb 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -1,5 +1,5 @@ import * as fabric from 'fabric' -import type { PenNode } from '@/types/pen' +import type { PenNode, ImageFitMode } from '@/types/pen' import type { PenFill, PenStroke, @@ -176,6 +176,86 @@ function cornerRadiusValue( return cr[0] } +/** + * Compute image scale, crop, and clip for a given fit mode. + * + * Fill/Crop uses FabricImage's native cropX/cropY to trim source pixels, + * avoiding a self-clipPath that would conflict with parent frame clipping. + * Corner radius is handled via a separate clipPath when needed. + */ +export function computeImageTransform( + nw: number, + nh: number, + w: number, + h: number, + mode: ImageFitMode = 'fill', + cornerRadius = 0, +): { + scaleX: number + scaleY: number + cropX: number + cropY: number + cropWidth: number + cropHeight: number + clipPath?: fabric.Rect +} { + switch (mode) { + case 'fill': + case 'crop': { + const ratio = Math.max(w / nw, h / nh) + // Crop dimensions in source pixels (center crop) + const cropW = w / ratio + const cropH = h / ratio + const cropX = (nw - cropW) / 2 + const cropY = (nh - cropH) / 2 + const clipR = cornerRadius > 0 ? cornerRadius / ratio : 0 + return { + scaleX: ratio, + scaleY: ratio, + cropX, + cropY, + cropWidth: cropW, + cropHeight: cropH, + clipPath: clipR > 0 + ? new fabric.Rect({ + width: cropW, + height: cropH, + rx: clipR, + ry: clipR, + originX: 'center', + originY: 'center', + }) + : undefined, + } + } + case 'fit': { + const ratio = Math.min(w / nw, h / nh) + const clipR = cornerRadius > 0 ? cornerRadius / ratio : 0 + return { + scaleX: ratio, + scaleY: ratio, + cropX: 0, + cropY: 0, + cropWidth: nw, + cropHeight: nh, + clipPath: clipR > 0 + ? new fabric.Rect({ + width: nw, + height: nh, + rx: clipR, + ry: clipR, + originX: 'center', + originY: 'center', + }) + : undefined, + } + } + default: + // 'tile' is handled at call site — fall through to stretch + return { scaleX: w / nw, scaleY: h / nh, cropX: 0, cropY: 0, cropWidth: nw, cropHeight: nh } + } +} + export interface FabricObjectWithPenId extends fabric.FabricObject { penNodeId?: string } @@ -368,35 +448,61 @@ export function createFabricObject( const w = sizeToNumber(node.width, 200) const h = sizeToNumber(node.height, 200) const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2) + const fitMode = node.objectFit ?? 'fill' const imgEl = new Image() imgEl.src = node.src - // Build a rounded-rect clipPath for corner radius - const makeImageClip = (cw: number, ch: number, cr: number) => { - if (cr <= 0) return undefined - return new fabric.Rect({ - width: cw, - height: ch, - rx: cr, - ry: cr, - originX: 'center', - originY: 'center', - }) + // Tile mode: use a Rect with a Pattern fill instead of FabricImage + if (fitMode === 'tile') { + const tileRect = new fabric.Rect({ + ...baseProps, + width: w, + height: h, + rx: r, + ry: r, + fill: '#e5e7eb', + strokeWidth: 0, + }) as FabricObjectWithPenId + ;(tileRect as any).__objectFit = 'tile' + const applyTilePattern = () => { + const canvas = tileRect.canvas + tileRect.set({ + fill: new fabric.Pattern({ + source: imgEl, + repeat: 'repeat', + }), + dirty: true, + }) + canvas?.requestRenderAll() + } + if (imgEl.complete) { + applyTilePattern() + } else { + tileRect.penNodeId = node.id + imgEl.onload = applyTilePattern + } + obj = tileRect + break } if (imgEl.complete) { const nw = imgEl.naturalWidth || w const nh = imgEl.naturalHeight || h - const clip = makeImageClip(nw, nh, r * nw / w) + const transform = computeImageTransform(nw, nh, w, h, fitMode, r) obj = new fabric.FabricImage(imgEl, { ...baseProps, - width: nw, - height: nh, - scaleX: w / nw, - scaleY: h / nh, - clipPath: clip ?? undefined, - objectCaching: !clip, + cropX: transform.cropX, + cropY: transform.cropY, + width: transform.cropWidth, + height: transform.cropHeight, + scaleX: transform.scaleX, + scaleY: transform.scaleY, + clipPath: transform.clipPath ?? undefined, + objectCaching: !transform.clipPath, }) as unknown as FabricObjectWithPenId + ;(obj as any).__objectFit = fitMode + ;(obj as any).__nativeWidth = nw + ;(obj as any).__nativeHeight = nh } else { // Placeholder while image loads const placeholder = new fabric.Rect({ @@ -409,21 +515,28 @@ export function createFabricObject( strokeWidth: 0, }) as FabricObjectWithPenId placeholder.penNodeId = node.id + ;(placeholder as any).__objectFit = fitMode imgEl.onload = () => { const canvas = placeholder.canvas if (!canvas) return const nw = imgEl.naturalWidth const nh = imgEl.naturalHeight + const transform = computeImageTransform(nw, nh, w, h, fitMode, r) const fabricImg = new fabric.FabricImage(imgEl, { ...baseProps, left: placeholder.left, top: placeholder.top, - width: nw, - height: nh, - scaleX: w / nw, - scaleY: h / nh, + cropX: transform.cropX, + cropY: transform.cropY, + width: transform.cropWidth, + height: transform.cropHeight, + scaleX: transform.scaleX, + scaleY: transform.scaleY, }) as unknown as FabricObjectWithPenId fabricImg.penNodeId = node.id + ;(fabricImg as any).__objectFit = fitMode + ;(fabricImg as any).__nativeWidth = nw + ;(fabricImg as any).__nativeHeight = nh fabricImg.set({ borderColor: SELECTION_BLUE, borderScaleFactor: 2, @@ -442,14 +555,13 @@ export function createFabricObject( fabricImg.visible = visible fabricImg.selectable = !locked fabricImg.evented = !locked - // Apply rounded-rect clip for corner radius - const clip = makeImageClip(nw, nh, r * nw / w) - if (clip) { - fabricImg.clipPath = clip + // Apply clipPath from transform (corner radius only) + if (transform.clipPath) { + fabricImg.clipPath = transform.clipPath fabricImg.objectCaching = false } // Preserve clipPath from placeholder so clipped-frame children stay clipped - if (!clip && placeholder.clipPath) { + if (!transform.clipPath && placeholder.clipPath) { fabricImg.clipPath = placeholder.clipPath fabricImg.dirty = true } diff --git a/src/canvas/canvas-object-sync.ts b/src/canvas/canvas-object-sync.ts index 2fce79949..ebc944117 100644 --- a/src/canvas/canvas-object-sync.ts +++ b/src/canvas/canvas-object-sync.ts @@ -7,6 +7,7 @@ import { resolveShadow, resolveStrokeColor, resolveStrokeWidth, + computeImageTransform, } from './canvas-object-factory' function sizeToNumber( @@ -153,27 +154,37 @@ export function syncFabricObject( const w = sizeToNumber(node.width, 200) const h = sizeToNumber(node.height, 200) const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2) - // Update scale to reflect target size over natural dimensions - const nw = obj.width || w - const nh = obj.height || h + const fitMode = node.objectFit ?? 'fill' + + // Detect mode changes that require object recreation (e.g. tile ↔ non-tile) + const prevMode = (obj as any).__objectFit ?? 'fill' + if (prevMode !== fitMode) { + ;(obj as any).__needsRecreation = true + return + } + + // Tile mode: update pattern fill on the Rect + if (fitMode === 'tile') { + obj.set({ width: w, height: h, rx: r, ry: r, dirty: true }) + break + } + + // Fill/Fit/Crop: use computeImageTransform with native (source) dimensions + const nw = (obj as any).__nativeWidth || obj.width || w + const nh = (obj as any).__nativeHeight || obj.height || h + const transform = computeImageTransform(nw, nh, w, h, fitMode, r) obj.set({ - scaleX: w / nw, - scaleY: h / nh, + cropX: transform.cropX, + cropY: transform.cropY, + width: transform.cropWidth, + height: transform.cropHeight, + scaleX: transform.scaleX, + scaleY: transform.scaleY, }) - // Apply or remove rounded-rect clipPath for corner radius. - // Disable objectCaching so clipPath updates render immediately - // without relying on Fabric's cache invalidation. - if (r > 0) { - const clipR = r * nw / w + // clipPath only for corner radius (fill/crop overflow handled by cropX/cropY) + if (transform.clipPath) { obj.set({ - clipPath: new fabric.Rect({ - width: nw, - height: nh, - rx: clipR, - ry: clipR, - originX: 'center', - originY: 'center', - }), + clipPath: transform.clipPath, objectCaching: false, dirty: true, }) diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 7af3eb35c..ec372b6eb 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -487,8 +487,19 @@ export function useCanvasSync() { continue } syncFabricObject(existingObj, node) - obj = existingObj - } else { + + // Check if sync flagged this object for recreation (e.g. image + // fit mode changed between tile ↔ non-tile, requiring a different + // Fabric class). + if ((existingObj as any).__needsRecreation) { + canvas.remove(existingObj) + existingObj = undefined + objectRecreated = true + } else { + obj = existingObj + } + } + if (!existingObj) { const newObj = createFabricObject(node) if (newObj) { const shouldAnimate = pendingAnimationNodes.has(node.id) diff --git a/src/canvas/use-fabric-canvas.ts b/src/canvas/use-fabric-canvas.ts index 441b62528..f76451b7a 100644 --- a/src/canvas/use-fabric-canvas.ts +++ b/src/canvas/use-fabric-canvas.ts @@ -39,7 +39,10 @@ function computeDocBounds(nodes: PenNode[], ox = 0, oy = 0) { maxX = Math.max(maxX, nx + (nw || 100)) maxY = Math.max(maxY, ny + (nh || 100)) - if ('children' in node && node.children && node.children.length > 0) { + // Only recurse into groups (their bounds come from children). + // Frames/rectangles have explicit width/height and clip children, + // so including children would inflate bounds beyond the visible area. + if (node.type === 'group' && 'children' in node && node.children && node.children.length > 0) { const child = computeDocBounds(node.children, nx, ny) minX = Math.min(minX, child.minX) minY = Math.min(minY, child.minY) diff --git a/src/components/editor/editor-layout.tsx b/src/components/editor/editor-layout.tsx index 039ca8b94..79ee6f595 100644 --- a/src/components/editor/editor-layout.tsx +++ b/src/components/editor/editor-layout.tsx @@ -20,6 +20,7 @@ import { useDocumentStore } from '@/stores/document-store' import { useAgentSettingsStore } from '@/stores/agent-settings-store' import { useUIKitStore } from '@/stores/uikit-store' import { useElectronMenu } from '@/hooks/use-electron-menu' +import { useFigmaPaste } from '@/hooks/use-figma-paste' const FabricCanvas = lazy(() => import('@/canvas/fabric-canvas')) @@ -108,6 +109,9 @@ export default function EditorLayout() { // Handle Electron native menu actions useElectronMenu() + // Handle Figma clipboard paste + useFigmaPaste() + // Hydrate persisted settings useEffect(() => { useAgentSettingsStore.getState().hydrate() diff --git a/src/components/panels/ai-chat-handlers.ts b/src/components/panels/ai-chat-handlers.ts index 68d872fbc..e5aadd613 100644 --- a/src/components/panels/ai-chat-handlers.ts +++ b/src/components/panels/ai-chat-handlers.ts @@ -135,6 +135,9 @@ export function useChatHandlers() { content: m.content, ...(m.attachments?.length ? { attachments: m.attachments } : {}), })) + const currentProvider = useAIStore.getState().modelGroups.find((g) => + g.models.some((m) => m.value === model), + )?.provider let accumulated = '' let appliedCount = 0 @@ -156,6 +159,8 @@ export function useChatHandlers() { const { rawResponse, nodes } = await generateDesignModification(selectedNodes, messageText, { variables: modDoc.variables, themes: modDoc.themes, + model, + provider: currentProvider, }, abortController.signal) accumulated = rawResponse updateLastMessage(accumulated) @@ -168,6 +173,8 @@ export function useChatHandlers() { const doc = useDocumentStore.getState().document const { rawResponse, nodes } = await generateDesign({ prompt: fullUserMessage, + model, + provider: currentProvider, context: { canvasSize: { width: 1200, height: 800 }, documentSummary: `Current selection: ${hasSelection ? selectedIds.length + ' items' : 'Empty'}`, @@ -200,10 +207,6 @@ export function useChatHandlers() { }) // 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 = '' for await (const chunk of streamChat( CHAT_SYSTEM_PROMPT, diff --git a/src/components/panels/image-section.tsx b/src/components/panels/image-section.tsx new file mode 100644 index 000000000..4c4f2889f --- /dev/null +++ b/src/components/panels/image-section.tsx @@ -0,0 +1,29 @@ +import type { ImageNode, ImageFitMode } from '@/types/pen' +import SectionHeader from '@/components/shared/section-header' +import DropdownSelect from '@/components/shared/dropdown-select' + +const FIT_MODE_OPTIONS: { value: string; label: string }[] = [ + { value: 'fill', label: 'Fill' }, + { value: 'fit', label: 'Fit' }, + { value: 'crop', label: 'Crop' }, + { value: 'tile', label: 'Tile' }, +] + +interface ImageSectionProps { + node: ImageNode + onUpdate: (updates: Partial) => void +} + +export default function ImageSection({ node, onUpdate }: ImageSectionProps) { + return ( +
+ + onUpdate({ objectFit: v as ImageFitMode })} + /> +
+ ) +} diff --git a/src/components/panels/property-panel.tsx b/src/components/panels/property-panel.tsx index 2bd794764..591d5605b 100644 --- a/src/components/panels/property-panel.tsx +++ b/src/components/panels/property-panel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, getActivePageChildren } from '@/stores/document-store' import { Separator } from '@/components/ui/separator' -import type { PenNode, ContainerProps, RefNode, PathNode } from '@/types/pen' +import type { PenNode, ContainerProps, RefNode, PathNode, ImageNode } 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' @@ -16,6 +16,7 @@ import TextLayoutSection from './text-layout-section' import EffectsSection from './effects-section' import ExportSection from './export-section' import IconSection from './icon-section' +import ImageSection from './image-section' /** Properties stored directly on the RefNode (instance-level), not as overrides. */ const INSTANCE_DIRECT_PROPS = new Set([ @@ -294,6 +295,18 @@ export default function PropertyPanel() { )} + {isImage && displayNode.type === 'image' && ( + <> + +
+ handleUpdate(updates as Partial)} + /> +
+ + )} +
diff --git a/src/components/shared/figma-import-dialog.tsx b/src/components/shared/figma-import-dialog.tsx index fa75b916a..4f3a3255a 100644 --- a/src/components/shared/figma-import-dialog.tsx +++ b/src/components/shared/figma-import-dialog.tsx @@ -123,7 +123,8 @@ export default function FigmaImportDialog({ open, onClose }: FigmaImportDialogPr // Load into the document store useDocumentStore.getState().loadDocument(doc, `${name}.op`) - requestAnimationFrame(() => zoomToFitContent()) + // Double-RAF ensures React effects (canvas sync) complete before fitting + requestAnimationFrame(() => requestAnimationFrame(() => zoomToFitContent())) setProgress(100) setState('done') @@ -253,12 +254,9 @@ export default function FigmaImportDialog({ open, onClose }: FigmaImportDialogPr 保持 Figma 布局 diff --git a/src/hooks/use-figma-paste.ts b/src/hooks/use-figma-paste.ts new file mode 100644 index 000000000..a87704a23 --- /dev/null +++ b/src/hooks/use-figma-paste.ts @@ -0,0 +1,191 @@ +import { useEffect } from 'react' +import { useCanvasStore } from '@/stores/canvas-store' +import { useDocumentStore } from '@/stores/document-store' +import { useHistoryStore } from '@/stores/history-store' +import { + isFigmaClipboardHtml, + extractFigmaClipboardData, + figmaClipboardToNodes, +} from '@/services/figma/figma-clipboard' +import type { PenNode } from '@/types/pen' + +/** + * Compute the bounding box of a set of PenNodes. + */ +function computeBounds(nodes: PenNode[]): { minX: number; minY: number; maxX: number; maxY: number } { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + + for (const node of nodes) { + const x = node.x ?? 0 + const y = node.y ?? 0 + + let right: number + let bottom: number + if (node.type === 'line') { + right = Math.max(x, node.x2 ?? x) + bottom = Math.max(y, node.y2 ?? y) + } else { + const w = 'width' in node && typeof node.width === 'number' ? node.width : 100 + const h = 'height' in node && typeof node.height === 'number' ? node.height : 100 + right = x + w + bottom = y + h + } + + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, right) + maxY = Math.max(maxY, bottom) + } + + return { minX, minY, maxX, maxY } +} + +/** + * Get the viewport center in scene coordinates using the Fabric canvas transform. + */ +function getViewportCenter(): { cx: number; cy: number } { + const canvas = useCanvasStore.getState().fabricCanvas + if (canvas) { + const vpt = canvas.viewportTransform + if (vpt) { + const zoom = vpt[0] + const cx = (-vpt[4] + canvas.getWidth() / 2) / zoom + const cy = (-vpt[5] + canvas.getHeight() / 2) / zoom + return { cx, cy } + } + } + return { cx: 0, cy: 0 } +} + +/** + * Process Figma HTML clipboard data — extract, decode, and add to canvas. + * Returns true if Figma nodes were pasted. + */ +function processFigmaHtml(html: string): boolean { + console.debug('[figma-paste] Figma markers detected, extracting clipboard data...') + + const clipData = extractFigmaClipboardData(html) + if (!clipData) { + console.warn('[figma-paste] Failed to extract clipboard data from HTML') + return false + } + + console.debug('[figma-paste] Extracted clipboard data, meta:', clipData.meta, + 'buffer size:', clipData.buffer.byteLength, 'bytes') + + const { nodes, warnings } = figmaClipboardToNodes(clipData.buffer) + console.debug('[figma-paste] Converted', nodes.length, 'nodes, warnings:', warnings) + + if (nodes.length === 0) { + console.warn('[figma-paste] No convertible nodes found:', warnings) + return false + } + + // Center pasted nodes at viewport center + const bounds = computeBounds(nodes) + const { cx, cy } = getViewportCenter() + const offsetX = cx - (bounds.minX + bounds.maxX) / 2 + const offsetY = cy - (bounds.minY + bounds.maxY) / 2 + + console.debug('[figma-paste] Bounds:', bounds, 'viewport center:', { cx, cy }, + 'offset:', { offsetX, offsetY }) + + for (const node of nodes) { + node.x = (node.x ?? 0) + offsetX + node.y = (node.y ?? 0) + offsetY + } + + // Batch all insertions into a single undo step + const doc = useDocumentStore.getState().document + useHistoryStore.getState().startBatch(doc) + + const newIds: string[] = [] + for (const node of nodes) { + useDocumentStore.getState().addNode(null, node) + newIds.push(node.id) + } + + useHistoryStore.getState().endBatch(useDocumentStore.getState().document) + + // Select the pasted nodes + useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null) + + console.debug('[figma-paste] Successfully pasted', newIds.length, 'nodes:', newIds) + return true +} + +/** + * Try reading Figma data from the system clipboard via Clipboard API. + * Used as a fallback when the `paste` event might not fire + * (e.g. when a non-editable element like has focus). + */ +export async function tryPasteFigmaFromClipboard(): Promise { + try { + // Try modern Clipboard API first + if (navigator.clipboard?.read) { + console.debug('[figma-paste] Reading clipboard via Clipboard API...') + const items = await navigator.clipboard.read() + for (const item of items) { + if (item.types.includes('text/html')) { + const blob = await item.getType('text/html') + const html = await blob.text() + console.debug('[figma-paste] Got HTML from clipboard, length:', html.length, + 'has figma markers:', isFigmaClipboardHtml(html)) + if (isFigmaClipboardHtml(html)) { + return processFigmaHtml(html) + } + } + } + console.debug('[figma-paste] No Figma data found in clipboard items') + } else { + console.debug('[figma-paste] Clipboard API not available') + } + } catch (err) { + console.warn('[figma-paste] Clipboard API read failed:', err) + } + return false +} + +/** + * Listens for browser `paste` events to detect Figma clipboard data. + * Also provides `tryPasteFigmaFromClipboard()` for use from the keydown + * handler as a fallback when the paste event might not fire. + */ +export function useFigmaPaste() { + useEffect(() => { + const handlePaste = (e: ClipboardEvent) => { + console.debug('[figma-paste] paste event fired, target:', (e.target as HTMLElement)?.tagName) + + // Skip if user is typing in an input/textarea/contentEditable + const target = e.target as HTMLElement + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + console.debug('[figma-paste] Skipping — editable element focused') + return + } + + const html = e.clipboardData?.getData('text/html') + console.debug('[figma-paste] clipboard HTML length:', html?.length ?? 0, + 'has figma markers:', html ? isFigmaClipboardHtml(html) : false) + + if (!html || !isFigmaClipboardHtml(html)) return + + e.preventDefault() + + try { + processFigmaHtml(html) + } catch (err) { + console.error('[figma-paste] Failed to paste Figma clipboard data:', err) + } + } + + document.addEventListener('paste', handlePaste) + return () => document.removeEventListener('paste', handlePaste) + }, []) +} diff --git a/src/hooks/use-keyboard-shortcuts.ts b/src/hooks/use-keyboard-shortcuts.ts index 7d1f5a99e..8d94bda0a 100644 --- a/src/hooks/use-keyboard-shortcuts.ts +++ b/src/hooks/use-keyboard-shortcuts.ts @@ -4,6 +4,7 @@ import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, getActivePageChildren } from '@/stores/document-store' import { useHistoryStore } from '@/stores/history-store' import { cloneNodesWithNewIds } from '@/utils/node-clone' +import { tryPasteFigmaFromClipboard } from '@/hooks/use-figma-paste' import { supportsFileSystemAccess, writeToFileHandle, @@ -147,6 +148,12 @@ export function useKeyboardShortcuts() { newIds.push(cloned.id) } useCanvasStore.getState().setSelection(newIds, newIds[0] ?? null) + } else { + // Internal clipboard empty — try reading Figma data from system clipboard. + // The native `paste` event may not fire when a non-editable element (canvas) + // has focus, so we also read via the Clipboard API as a fallback. + e.preventDefault() + tryPasteFigmaFromClipboard() } return } diff --git a/src/services/ai/ai-service.ts b/src/services/ai/ai-service.ts index 679c831f2..12749ea7a 100644 --- a/src/services/ai/ai-service.ts +++ b/src/services/ai/ai-service.ts @@ -42,7 +42,7 @@ interface StreamChatOptions { /** * Streams a chat response from the server-side AI endpoint. - * The server uses ANTHROPIC_API_KEY or local Agent SDK (no client-side key needed). + * The server routes to the appropriate provider SDK (no client-side key needed). */ export async function* streamChat( systemPrompt: string, @@ -300,7 +300,7 @@ export async function* streamChat( /** * Non-streaming completion for design/code generation. - * Calls the server-side endpoint which reads ANTHROPIC_API_KEY from env. + * Calls the server-side endpoint which routes to the appropriate provider SDK. */ export async function generateCompletion( systemPrompt: string, diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts index 78e6f7d14..efdb3ba6a 100644 --- a/src/services/ai/ai-types.ts +++ b/src/services/ai/ai-types.ts @@ -1,3 +1,5 @@ +import type { AIProviderType } from '@/types/agent-settings' + export interface ChatAttachment { id: string name: string @@ -17,6 +19,8 @@ export interface ChatMessage { export interface AIDesignRequest { prompt: string + model?: string + provider?: AIProviderType context?: { selectedNodes?: string[] documentSummary?: string diff --git a/src/services/ai/design-canvas-ops.ts b/src/services/ai/design-canvas-ops.ts index 4068ec3b9..795a27895 100644 --- a/src/services/ai/design-canvas-ops.ts +++ b/src/services/ai/design-canvas-ops.ts @@ -223,7 +223,8 @@ export function insertStreamingNode( startNewAnimationBatch() } - addNode(insertParent, node) + // Append (not prepend) so auto-layout children stay in generation order + addNode(insertParent, node, Infinity) // When a frame is inserted into a horizontal layout, equalize sibling card widths // to prevent overflow when multiple cards are placed in the same row. @@ -260,7 +261,7 @@ export function applyNodesToCanvas(nodes: PenNode[]): void { const rootFrame = getNodeById(DEFAULT_FRAME_ID) const parentId = rootFrame ? DEFAULT_FRAME_ID : null for (const node of preparedNodes) { - addNode(parentId, node) + addNode(parentId, node, Infinity) } adjustRootFrameHeightToContent() resolveAllPendingIcons().catch(console.warn) @@ -288,7 +289,7 @@ export function upsertNodesToCanvas(nodes: PenNode[]): number { const merged = mergeNodeForProgressiveUpsert(existing, remappedNode) updateNode(resolvedId, merged) } else { - addNode(parentId, node) + addNode(parentId, node, Infinity) } count++ } @@ -318,7 +319,7 @@ function upsertPreparedNodes(preparedNodes: PenNode[]): number { const merged = mergeNodeForProgressiveUpsert(existing, remappedNode) updateNode(resolvedId, merged) } else { - addNode(parentId, node) + addNode(parentId, node, Infinity) } count++ } diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index 4bac056b7..03d8e209a 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -1,5 +1,6 @@ import type { PenNode } from '@/types/pen' import type { VariableDefinition, ThemedValue } from '@/types/variables' +import type { AIProviderType } from '@/types/agent-settings' import type { AIDesignRequest } from './ai-types' import { streamChat } from './ai-service' import { DESIGN_MODIFIER_PROMPT } from './ai-prompts' @@ -91,6 +92,8 @@ export async function generateDesignModification( options?: { variables?: Record themes?: Record + model?: string + provider?: AIProviderType }, abortSignal?: AbortSignal, ): Promise<{ nodes: PenNode[]; rawResponse: string }> { @@ -113,7 +116,7 @@ export async function generateDesignModification( for await (const chunk of streamChat(DESIGN_MODIFIER_PROMPT, [ { role: 'user', content: userMessage }, - ], undefined, DESIGN_STREAM_TIMEOUTS, undefined, abortSignal)) { + ], options?.model, DESIGN_STREAM_TIMEOUTS, options?.provider, abortSignal)) { if (chunk.type === 'thinking') { // Ignore thinking chunks for modification -- caller already shows progress } else if (chunk.type === 'text') { diff --git a/src/services/ai/design-validation.ts b/src/services/ai/design-validation.ts index e38e2ca77..d70c5a1e1 100644 --- a/src/services/ai/design-validation.ts +++ b/src/services/ai/design-validation.ts @@ -11,6 +11,7 @@ import { DEFAULT_FRAME_ID, useDocumentStore } from '@/stores/document-store' import { VALIDATION_TIMEOUT_MS } from './ai-runtime-config' import type { PenNode } from '@/types/pen' import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory' +import type { AIProviderType } from '@/types/agent-settings' // --------------------------------------------------------------------------- // System prompt for the vision validator @@ -175,6 +176,8 @@ interface ValidationResult { async function validateDesignScreenshot( imageBase64: string, nodeTreeDump: string, + model?: string, + provider?: AIProviderType, ): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), VALIDATION_TIMEOUT_MS) @@ -195,6 +198,8 @@ Cross-reference visual issues with the node IDs above. Return JSON fixes using r system: VALIDATION_SYSTEM_PROMPT, message, imageBase64, + model, + provider, }), signal: controller.signal, }) @@ -293,11 +298,13 @@ function applyValidationFixes(result: ValidationResult): number { // --------------------------------------------------------------------------- export async function runPostGenerationValidation( - callbacks?: { + options?: { onStatusUpdate?: (status: 'pending' | 'streaming' | 'done' | 'error', message?: string) => void + model?: string + provider?: AIProviderType }, ): Promise<{ applied: number; skipped: boolean }> { - callbacks?.onStatusUpdate?.('streaming', 'Capturing screenshot...') + options?.onStatusUpdate?.('streaming', 'Capturing screenshot...') // Wait for canvas render to stabilize await new Promise((resolve) => { @@ -309,19 +316,24 @@ export async function runPostGenerationValidation( const imageBase64 = captureRootFrameScreenshot() if (!imageBase64) { console.warn('[Validation] Could not capture screenshot — skipping') - callbacks?.onStatusUpdate?.('done', 'Skipped (no screenshot)') + options?.onStatusUpdate?.('done', 'Skipped (no screenshot)') return { applied: 0, skipped: true } } // Build simplified node tree for LLM context const nodeTreeDump = buildNodeTreeDump(DEFAULT_FRAME_ID) - callbacks?.onStatusUpdate?.('streaming', 'Analyzing design...') - const result = await validateDesignScreenshot(imageBase64, nodeTreeDump) + options?.onStatusUpdate?.('streaming', 'Analyzing design...') + const result = await validateDesignScreenshot( + imageBase64, + nodeTreeDump, + options?.model, + options?.provider, + ) if (result.skipped) { console.log('[Validation] Skipped (provider unsupported)') - callbacks?.onStatusUpdate?.('done', 'Skipped') + options?.onStatusUpdate?.('done', 'Skipped') return { applied: 0, skipped: true } } @@ -331,13 +343,13 @@ export async function runPostGenerationValidation( if (result.fixes.length === 0) { console.log('[Validation] No fixes needed') - callbacks?.onStatusUpdate?.('done', 'No issues found') + options?.onStatusUpdate?.('done', 'No issues found') return { applied: 0, skipped: false } } - callbacks?.onStatusUpdate?.('streaming', `Applying ${result.fixes.length} fixes...`) + options?.onStatusUpdate?.('streaming', `Applying ${result.fixes.length} fixes...`) const applied = applyValidationFixes(result) console.log(`[Validation] Applied ${applied} fixes:`, result.fixes) - callbacks?.onStatusUpdate?.('done', `Applied ${applied} fixes`) + options?.onStatusUpdate?.('done', `Applied ${applied} fixes`) return { applied, skipped: false } } diff --git a/src/services/ai/orchestrator-sub-agent.ts b/src/services/ai/orchestrator-sub-agent.ts index 3d3929b57..63e518084 100644 --- a/src/services/ai/orchestrator-sub-agent.ts +++ b/src/services/ai/orchestrator-sub-agent.ts @@ -161,9 +161,9 @@ async function executeSubAgent( for await (const chunk of streamChat( SUB_AGENT_PROMPT, [{ role: 'user', content: userPrompt }], - undefined, + request.model, timeoutOptions, - undefined, + request.provider, abortSignal, )) { if (chunk.type === 'text') { @@ -390,4 +390,3 @@ function needsHeroPhoneTwoColumnInstruction( const phoneLike = /(phone|mockup|screenshot|截图|手机|app\s*screen|应用截图)/.test(text) return heroLike && phoneLike } - diff --git a/src/services/ai/orchestrator.ts b/src/services/ai/orchestrator.ts index 58ee58257..852770abf 100644 --- a/src/services/ai/orchestrator.ts +++ b/src/services/ai/orchestrator.ts @@ -70,6 +70,8 @@ export async function executeOrchestration( const plan = await callOrchestrator( preparedPrompt.orchestratorPrompt, preparedPrompt.originalLength, + request.model, + request.provider, (thinking) => { renderPlanningStatus(thinking) }, @@ -232,6 +234,8 @@ export async function executeOrchestration( validationEntry.thinking = message emitProgress(plan, progress, callbacks) }, + model: request.model, + provider: request.provider, }) if (validationResult.applied > 0) { validationEntry.nodeCount = validationResult.applied @@ -262,6 +266,8 @@ export async function executeOrchestration( async function callOrchestrator( prompt: string, timeoutHintLength: number, + model?: string, + provider?: AIDesignRequest['provider'], onThinking?: (thinking: string) => void, abortSignal?: AbortSignal, ): Promise { @@ -271,9 +277,9 @@ async function callOrchestrator( for await (const chunk of streamChat( ORCHESTRATOR_PROMPT, [{ role: 'user', content: prompt }], - undefined, + model, getOrchestratorTimeouts(timeoutHintLength), - undefined, + provider, abortSignal, )) { if (chunk.type === 'text') { diff --git a/src/services/figma/figma-clipboard.ts b/src/services/figma/figma-clipboard.ts new file mode 100644 index 000000000..b2535c315 --- /dev/null +++ b/src/services/figma/figma-clipboard.ts @@ -0,0 +1,179 @@ +import type { PenNode } from '@/types/pen' +import { parseFigFile } from './fig-parser' +import { figmaNodeChangesToPenNodes } from './figma-node-mapper' +import { resolveImageBlobs } from './figma-image-resolver' + +/** + * Quick check: does this HTML string contain Figma clipboard markers? + * Figma wraps its data in `` comment blocks or uses + * `data-metadata` / `data-buffer` attributes. + */ +export function isFigmaClipboardHtml(html: string): boolean { + return html.includes('figmeta') || html.includes('data-buffer') +} + +// Standard base64 lookup table +const B64_LOOKUP = new Uint8Array(256) +{ + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + for (let i = 0; i < chars.length; i++) B64_LOOKUP[chars.charCodeAt(i)] = i + // URL-safe variants + B64_LOOKUP['-'.charCodeAt(0)] = 62 + B64_LOOKUP['_'.charCodeAt(0)] = 63 +} + +/** + * Decode a base64 string to Uint8Array without relying on atob. + * Handles URL-safe alphabet, whitespace, missing padding, and stray characters. + */ +function decodeBase64ToBytes(input: string): Uint8Array { + // Strip everything except valid base64 characters + const b64 = input.replace(/[^A-Za-z0-9+/\-_=]/g, '') + + const len = b64.length + // Compute output byte length (ignoring padding) + const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0 + const byteLen = Math.floor(len * 3 / 4) - padding + + const bytes = new Uint8Array(byteLen) + let p = 0 + + for (let i = 0; i < len; i += 4) { + const a = B64_LOOKUP[b64.charCodeAt(i)] + const b = B64_LOOKUP[b64.charCodeAt(i + 1)] + const c = B64_LOOKUP[b64.charCodeAt(i + 2)] + const d = B64_LOOKUP[b64.charCodeAt(i + 3)] + + if (p < byteLen) bytes[p++] = (a << 2) | (b >> 4) + if (p < byteLen) bytes[p++] = ((b & 0x0F) << 4) | (c >> 2) + if (p < byteLen) bytes[p++] = ((c & 0x03) << 6) | d + } + + return bytes +} + +/** + * Decode a base64 string to a UTF-8 string. + */ +function decodeBase64(input: string): string { + const bytes = decodeBase64ToBytes(input) + return new TextDecoder().decode(bytes) +} + +interface FigmaClipboardData { + meta: Record + buffer: ArrayBuffer +} + +/** + * Extract and decode Figma clipboard data from the HTML payload. + * + * Figma writes two comment-wrapped, base64-encoded blocks in various formats: + * Format A (in HTML comments): + * BASE64_JSON + * BASE64_BINARY + * Format B (in data attributes): + * + * + */ +export function extractFigmaClipboardData(html: string): FigmaClipboardData | null { + console.debug('[figma-clipboard] HTML preview (first 500 chars):', html.slice(0, 500)) + + let metaB64: string | null = null + let bufferB64: string | null = null + + // Strategy 1: comment-wrapped format + // Figma uses (opening lacks -->) + // or BASE64 (both have -->) + const metaCommentMatch = html.match(/)?([\s\S]*?)/) + const bufferCommentMatch = html.match(/)?([\s\S]*?)/) + + if (metaCommentMatch && bufferCommentMatch) { + console.debug('[figma-clipboard] Matched comment-wrapped format') + metaB64 = metaCommentMatch[1].trim() + bufferB64 = bufferCommentMatch[1].trim() + } + + // Strategy 2: data-attribute format (the comments may be inside attribute values) + if (!metaB64 || !bufferB64) { + const attrMetaMatch = html.match(/data-metadata="([^"]*)"/) + const attrBufferMatch = html.match(/data-buffer="([^"]*)"/) + + if (attrMetaMatch && attrBufferMatch) { + console.debug('[figma-clipboard] Matched data-attribute format') + // Strip comment wrappers from attribute values if present. + // Opening marker may lack --> (e.g. "") + metaB64 = attrMetaMatch[1] + .replace(/)?/g, '') + .trim() + bufferB64 = attrBufferMatch[1] + .replace(/)?/g, '') + .trim() + } + } + + // Strategy 3: HTML-encoded comment markers inside attributes + if (!metaB64 || !bufferB64) { + const encodedMetaMatch = html.match(/<!--\(figmeta\)-->([\s\S]*?)<!--\(figmeta\)-->/) + const encodedBufferMatch = html.match(/<!--\(figma\)-->([\s\S]*?)<!--\(figma\)-->/) + + if (encodedMetaMatch && encodedBufferMatch) { + console.debug('[figma-clipboard] Matched HTML-encoded comment format') + metaB64 = encodedMetaMatch[1].trim() + bufferB64 = encodedBufferMatch[1].trim() + } + } + + if (!metaB64 || !bufferB64) { + console.warn('[figma-clipboard] No matching extraction strategy.', + 'Has figmeta comment:', //.test(html), + 'Has figma comment:', //.test(html), + 'Has data-metadata attr:', /data-metadata=/.test(html), + 'Has data-buffer attr:', /data-buffer=/.test(html), + 'Has encoded figmeta:', /<!--\(figmeta\)/.test(html), + ) + return null + } + + console.debug('[figma-clipboard] meta base64 length:', metaB64.length, + 'buffer base64 length:', bufferB64.length) + + try { + const metaRaw = decodeBase64(metaB64) + // Trim trailing junk bytes from base64 padding — extract only the JSON object + const jsonEnd = metaRaw.lastIndexOf('}') + const metaJson = jsonEnd >= 0 ? metaRaw.slice(0, jsonEnd + 1) : metaRaw + const meta = JSON.parse(metaJson) + console.debug('[figma-clipboard] Decoded meta:', meta) + + const bytes = decodeBase64ToBytes(bufferB64) + + console.debug('[figma-clipboard] Decoded buffer:', bytes.byteLength, 'bytes,', + 'first 8 bytes:', Array.from(bytes.slice(0, 8)).map(b => b.toString(16).padStart(2, '0')).join(' ')) + + return { meta, buffer: bytes.buffer as ArrayBuffer } + } catch (err) { + console.error('[figma-clipboard] Decode error:', err, + 'meta b64 preview:', metaB64.slice(0, 80), + 'buffer b64 preview:', bufferB64.slice(0, 80)) + return null + } +} + +/** + * Convert a Figma clipboard buffer into PenNodes. + * The buffer uses the same fig-kiwi binary format as .fig files. + */ +export function figmaClipboardToNodes( + buffer: ArrayBuffer, +): { nodes: PenNode[]; warnings: string[] } { + const decoded = parseFigFile(buffer) + const { nodes, warnings, imageBlobs } = figmaNodeChangesToPenNodes(decoded, 'openpencil') + + // Resolve embedded image blobs to data URLs + if (imageBlobs.size > 0 || decoded.imageFiles.size > 0) { + resolveImageBlobs(nodes, imageBlobs, decoded.imageFiles) + } + + return { nodes, warnings } +} diff --git a/src/services/figma/figma-node-mapper.ts b/src/services/figma/figma-node-mapper.ts index cfd0304cb..f1d9fd78e 100644 --- a/src/services/figma/figma-node-mapper.ts +++ b/src/services/figma/figma-node-mapper.ts @@ -5,7 +5,7 @@ import type { FigmaMatrix, FigmaImportLayoutMode, } from './figma-types' -import type { PenNode, PenPage, PenDocument, SizingBehavior } from '@/types/pen' +import type { PenNode, PenPage, PenDocument, SizingBehavior, ImageFitMode } from '@/types/pen' import { mapFigmaFills } from './figma-fill-mapper' import { mapFigmaStroke } from './figma-stroke-mapper' import { mapFigmaEffects } from './figma-effect-mapper' @@ -242,6 +242,52 @@ function buildTree(nodeChanges: FigmaNodeChange[]): TreeNode | null { return root } +/** + * Build a tree from clipboard nodeChanges that may lack a DOCUMENT wrapper. + * Collects orphan nodes (whose parent is not in the data) as roots. + */ +function buildTreeForClipboard(nodeChanges: FigmaNodeChange[]): TreeNode[] { + const nodeMap = new Map() + const childKeys = new Set() + + for (const nc of nodeChanges) { + if (!nc.guid) continue + if (nc.phase === 'REMOVED') continue + const key = guidToString(nc.guid) + nodeMap.set(key, { figma: nc, children: [] }) + } + + for (const nc of nodeChanges) { + if (!nc.guid || nc.phase === 'REMOVED') continue + const key = guidToString(nc.guid) + const treeNode = nodeMap.get(key) + if (!treeNode) continue + + if (nc.parentIndex?.guid) { + const parentKey = guidToString(nc.parentIndex.guid) + const parent = nodeMap.get(parentKey) + if (parent) { + parent.children.push(treeNode) + childKeys.add(key) + } + } + } + + // Roots = nodes that are not children of any other node in the data + const roots: TreeNode[] = [] + for (const [key, node] of nodeMap) { + if (!childKeys.has(key) && node.figma.type !== 'DOCUMENT') { + roots.push(node) + } + } + + for (const root of roots) { + sortChildrenRecursive(root) + } + + return roots +} + function sortChildrenRecursive(node: TreeNode): void { node.children.sort((a, b) => { const posA = a.figma.parentIndex?.position ?? '' @@ -430,6 +476,7 @@ function convertFrame( type: 'image', ...commonProps(figma, id), src: getImageFillUrl(figma), + objectFit: getImageFitMode(figma), width: resolveWidth(figma, parentStackMode, ctx), height: resolveHeight(figma, parentStackMode, ctx), cornerRadius: mapCornerRadius(figma), @@ -542,6 +589,7 @@ function convertRectangle( type: 'image', ...commonProps(figma, id), src: getImageFillUrl(figma), + objectFit: getImageFitMode(figma), width: resolveWidth(figma, parentStackMode, ctx), height: resolveHeight(figma, parentStackMode, ctx), cornerRadius: mapCornerRadius(figma), @@ -575,6 +623,7 @@ function convertEllipse( type: 'image', ...commonProps(figma, id), src: getImageFillUrl(figma), + objectFit: getImageFitMode(figma), width: resolveWidth(figma, parentStackMode, ctx), height: resolveHeight(figma, parentStackMode, ctx), cornerRadius: Math.round((figma.size?.x ?? 100) / 2), @@ -707,6 +756,19 @@ function figmaFillColor(figma: FigmaNodeChange): string | undefined { // --- Helpers --- +function getImageFitMode(figma: FigmaNodeChange): ImageFitMode | undefined { + const paint = figma.fillPaints?.find( + (f) => f.visible !== false && f.type === 'IMAGE', + ) + if (!paint?.imageScaleMode) return undefined + switch (paint.imageScaleMode) { + case 'FIT': return 'fit' + case 'FILL': return 'fill' + case 'TILE': return 'tile' + default: return undefined + } +} + function hasOnlyImageFill(figma: FigmaNodeChange): boolean { if (!figma.fillPaints || figma.fillPaints.length === 0) return false const visible = figma.fillPaints.filter((f) => f.visible !== false) @@ -747,3 +809,66 @@ function collectImageBlobs(blobs: (Uint8Array | string)[]): Map } { + const warnings: string[] = [] + + // Try full tree first (clipboard may include DOCUMENT+CANVAS) + const tree = buildTree(decoded.nodeChanges) + let topNodes: TreeNode[] + + if (tree) { + // Has a DOCUMENT root — find CANVAS pages and use the first one's children + const pages = tree.children.filter(isUserPage) + const page = pages[0] + if (page) { + topNodes = page.children + } else if (tree.children.length > 0) { + // DOCUMENT exists but no CANVAS — use direct children + topNodes = tree.children + } else { + topNodes = [] + } + } else { + // No DOCUMENT root — collect orphan nodes as roots + topNodes = buildTreeForClipboard(decoded.nodeChanges) + } + + if (topNodes.length === 0) { + return { nodes: [], warnings: ['No convertible nodes found'], imageBlobs: new Map() } + } + + // Collect components for instance resolution + const componentMap = new Map() + let idCounter = 1 + const genId = () => `fig_${idCounter++}` + for (const node of topNodes) { + collectComponents(node, componentMap, genId) + } + + const ctx: ConversionContext = { + componentMap, + warnings, + generateId: genId, + blobs: decoded.blobs, + layoutMode, + } + + const nodes: PenNode[] = [] + for (const treeNode of topNodes) { + if (treeNode.figma.visible === false) continue + const node = convertNode(treeNode, undefined, ctx) + if (node) nodes.push(node) + } + + const imageBlobs = collectImageBlobs(decoded.blobs) + + return { nodes, warnings, imageBlobs } +} diff --git a/src/types/pen.ts b/src/types/pen.ts index 366bc16bc..4c45ff0c9 100644 --- a/src/types/pen.ts +++ b/src/types/pen.ts @@ -164,9 +164,12 @@ export interface TextNode extends PenNodeBase { effects?: PenEffect[] } +export type ImageFitMode = 'fill' | 'fit' | 'crop' | 'tile' + export interface ImageNode extends PenNodeBase { type: 'image' src: string + objectFit?: ImageFitMode width?: SizingBehavior height?: SizingBehavior cornerRadius?: number | [number, number, number, number]