* 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 <fini.yang@gmail.com>
This commit is contained in:
Kayshen Xu 2026-03-02 22:26:09 +08:00 committed by GitHub
parent 33a1488339
commit ffbd2be112
28 changed files with 1009 additions and 434 deletions

View file

@ -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'

View file

@ -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

View file

@ -27,6 +27,7 @@ mac:
- zip
hardenedRuntime: true
gatekeeperAssess: false
notarize: true
dmg:
title: "${productName} ${version}"

View file

@ -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",

View file

@ -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<string[] | u
}
}
function shouldRetryClaudeWithoutModel(raw: string): boolean {
return /process exited with code 1|invalid model|unknown model|model.*not/i.test(raw)
}
function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | undefined {
if (!/process exited with code 1/i.test(rawError)) return undefined
if (!debugTail || debugTail.length === 0) return undefined
@ -62,8 +58,8 @@ function buildClaudeExitHint(rawError: string, debugTail?: string[]): string | u
/**
* Streaming chat 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<ChatBody>(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<Record<string, unknown>> = [
...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<string> => {
const runImageQuery = async (): Promise<string> => {
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(

View file

@ -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<GenerateBody>(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 }
}
}

View file

@ -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<ValidateBody>(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<T>(
imageBase64: string,
run: (tempPath: string) => Promise<T>,
): Promise<T> {
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)
}
}

View file

@ -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
}

View file

@ -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,
})

View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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,

View file

@ -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<ImageNode>) => void
}
export default function ImageSection({ node, onUpdate }: ImageSectionProps) {
return (
<div className="space-y-1.5">
<SectionHeader title="Image" />
<DropdownSelect
label="Fit"
value={node.objectFit ?? 'fill'}
options={FIT_MODE_OPTIONS}
onChange={(v) => onUpdate({ objectFit: v as ImageFitMode })}
/>
</div>
)
}

View file

@ -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' && (
<>
<Separator />
<div className="px-3 py-2">
<ImageSection
node={displayNode as ImageNode}
onUpdate={(updates) => handleUpdate(updates as Partial<PenNode>)}
/>
</div>
</>
)}
<Separator />
<div className="px-3 py-2">

View file

@ -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
</button>
<button
className={`flex-1 px-3 py-1.5 rounded text-xs transition-colors ${
layoutMode === 'openpencil'
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-foreground hover:bg-secondary/80'
}`}
onClick={() => setLayoutMode('openpencil')}
className="flex-1 px-3 py-1.5 rounded text-xs transition-colors bg-secondary text-muted-foreground cursor-not-allowed opacity-50"
disabled
title="即将支持"
>
OpenPencil
</button>

View file

@ -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 <canvas> has focus).
*/
export async function tryPasteFigmaFromClipboard(): Promise<boolean> {
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)
}, [])
}

View file

@ -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
}

View file

@ -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,

View file

@ -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

View file

@ -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++
}

View file

@ -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<string, VariableDefinition>
themes?: Record<string, string[]>
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') {

View file

@ -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<ValidationResult> {
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<void>((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 }
}

View file

@ -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
}

View file

@ -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<OrchestratorPlan> {
@ -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') {

View file

@ -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 `<!--(figmeta)-->` 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<string, unknown>
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):
* <!--(figmeta)-->BASE64_JSON<!--(figmeta)-->
* <!--(figma)-->BASE64_BINARY<!--(figma)-->
* Format B (in data attributes):
* <span data-metadata="BASE64_JSON"></span>
* <span data-buffer="BASE64_BINARY"></span>
*/
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 <!--(figmeta)BASE64<!--(figmeta)--> (opening lacks -->)
// or <!--(figmeta)-->BASE64<!--(figmeta)--> (both have -->)
const metaCommentMatch = html.match(/<!--\(figmeta\)(?:-->)?([\s\S]*?)<!--\(figmeta\)-->/)
const bufferCommentMatch = html.match(/<!--\(figma\)(?:-->)?([\s\S]*?)<!--\(figma\)-->/)
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. "<!--(figmeta)BASE64<!--(figmeta)-->")
metaB64 = attrMetaMatch[1]
.replace(/<!--\(figmeta\)(-->)?/g, '')
.trim()
bufferB64 = attrBufferMatch[1]
.replace(/<!--\(figma\)(-->)?/g, '')
.trim()
}
}
// Strategy 3: HTML-encoded comment markers inside attributes
if (!metaB64 || !bufferB64) {
const encodedMetaMatch = html.match(/&lt;!--\(figmeta\)--&gt;([\s\S]*?)&lt;!--\(figmeta\)--&gt;/)
const encodedBufferMatch = html.match(/&lt;!--\(figma\)--&gt;([\s\S]*?)&lt;!--\(figma\)--&gt;/)
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:', /<!--\(figmeta\)-->/.test(html),
'Has figma comment:', /<!--\(figma\)-->/.test(html),
'Has data-metadata attr:', /data-metadata=/.test(html),
'Has data-buffer attr:', /data-buffer=/.test(html),
'Has encoded figmeta:', /&lt;!--\(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 }
}

View file

@ -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<string, TreeNode>()
const childKeys = new Set<string>()
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<number, Uint8Arr
}
return map
}
/**
* Convert decoded Figma nodeChanges directly to PenNodes (without wrapping in a PenDocument).
* Used for clipboard paste where the data may lack a DOCUMENT+CANVAS wrapper.
*/
export function figmaNodeChangesToPenNodes(
decoded: FigmaDecodedFile,
layoutMode: FigmaImportLayoutMode = 'openpencil',
): { nodes: PenNode[]; warnings: string[]; imageBlobs: Map<number, Uint8Array> } {
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<string, string>()
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 }
}

View file

@ -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]