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 <fini.yang@gmail.com>
This commit is contained in:
parent
33a1488339
commit
ffbd2be112
5
.github/workflows/build-electron.yml
vendored
5
.github/workflows/build-electron.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ mac:
|
|||
- zip
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
notarize: true
|
||||
|
||||
dmg:
|
||||
title: "${productName} ${version}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
29
src/components/panels/image-section.tsx
Normal file
29
src/components/panels/image-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
191
src/hooks/use-figma-paste.ts
Normal file
191
src/hooks/use-figma-paste.ts
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
179
src/services/figma/figma-clipboard.ts
Normal file
179
src/services/figma/figma-clipboard.ts
Normal 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(/<!--\(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:', /<!--\(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:', /<!--\(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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue