diff --git a/README-zh.md b/README-zh.md index 3a5e1b7..09f4bc5 100644 --- a/README-zh.md +++ b/README-zh.md @@ -54,7 +54,7 @@ npm install npm run dev ``` -服务器将在 `http://localhost:7788` 启动。在浏览器中打开此 URL 以访问预览界面。 +服务器将在 `http://localhost:7789` 启动。在浏览器中打开此 URL 以访问预览界面。 ### 使用 Figma 插件 diff --git a/README.md b/README.md index 9ecedf1..9ea5e58 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ npm install npm run dev ``` -The server will start at `http://localhost:7788`. Open this URL in your browser to access the preview interface. +The server will start at `http://localhost:7789`. Open this URL in your browser to access the preview interface. ### Using the Figma Plugin diff --git a/code.js b/code.js index 323c71f..96fcb94 100644 --- a/code.js +++ b/code.js @@ -1,4 +1,4 @@ -figma.showUI(__html__, { width: 280, height: 160, themeColors: true }); +figma.showUI(__html__, { width: 360, height: 520, themeColors: true }); function sanitizeImageId(hash) { if (!hash || typeof hash !== 'string') return null; @@ -571,11 +571,11 @@ async function collectNode(n, opts) { return entry; } -async function buildCompositionFromSelection() { - const selection = figma.currentPage.selection || []; - if (!selection.length) return null; +async function buildCompositionFromNodes(nodes, compositionName) { + const sourceNodes = Array.isArray(nodes) ? nodes : []; + if (!sourceNodes.length) return null; - const sorted = sortByDocumentOrder(selection); + const sorted = sortByDocumentOrder(sourceNodes); let renderables = expandGroupsWithAncestors(sorted); if (!renderables.length) return null; @@ -613,7 +613,7 @@ async function buildCompositionFromSelection() { const root = { schemaVersion: '1.0', kind: 'composition', - name: `Composition (${children.length} items)`, + name: compositionName || `Composition (${children.length} items)`, absOrigin: { x: offsetX, y: offsetY }, bounds: { x: 0, y: 0, width: boundsWidth, height: boundsHeight }, children @@ -622,6 +622,11 @@ async function buildCompositionFromSelection() { return root; } +async function buildCompositionFromSelection() { + const selection = figma.currentPage.selection || []; + return buildCompositionFromNodes(selection); +} + function collectImageIdsFromComposition(comp) { const ids = []; const seen = new Set(); @@ -651,9 +656,86 @@ function collectImageIdsFromComposition(comp) { } async function notifyComposition() { - const composition = await buildCompositionFromSelection(); + const selection = figma.currentPage.selection || []; + const composition = await buildCompositionFromNodes(selection); const imageIds = composition ? collectImageIdsFromComposition(composition) : []; figma.ui.postMessage({ type: 'send-composition', composition, imageIds }); + figma.ui.postMessage({ + type: 'send-export-selection', + items: selection + .filter((node) => !!node && node.visible !== false) + .map((node) => ({ id: node.id, name: typeof node.name === 'string' ? node.name : node.id, type: node.type })), + }); +} + +const batchPageAckWaiters = new Map(); + +function waitForBatchPageAck(pageId, timeoutMs) { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + batchPageAckWaiters.delete(pageId); + resolve({ success: false, error: 'Timed out waiting for export acknowledgement' }); + }, timeoutMs || 120000); + batchPageAckWaiters.set(pageId, (result) => { + clearTimeout(timeout); + batchPageAckWaiters.delete(pageId); + resolve(result || { success: false, error: 'Missing export acknowledgement' }); + }); + }); +} + +async function exportSelectedNodes(nodeIds, targetDir) { + const requestedIds = Array.isArray(nodeIds) ? nodeIds.filter((id) => typeof id === 'string') : []; + const nodes = (figma.currentPage.selection || []).filter((node) => requestedIds.includes(node.id) && node.visible !== false); + const total = nodes.length; + const summary = { total, exported: 0, skipped: 0, failed: 0, results: [] }; + const usedFolderNames = new Map(); + + figma.ui.postMessage({ type: 'batch-export:started', total }); + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]; + const pageName = typeof node.name === 'string' ? node.name : node.id; + const baseFolderName = String(pageName || 'selection').trim() || 'selection'; + const previousCount = usedFolderNames.get(baseFolderName) || 0; + usedFolderNames.set(baseFolderName, previousCount + 1); + const exportFolderName = previousCount > 0 ? `${baseFolderName} (${previousCount + 1})` : baseFolderName; + try { + const composition = await buildCompositionFromNodes([node], pageName); + if (!composition || !Array.isArray(composition.children) || composition.children.length === 0) { + summary.skipped += 1; + summary.results.push({ pageId: node.id, pageName, status: 'skipped', reason: 'No visible layers' }); + figma.ui.postMessage({ type: 'batch-export:page-skipped', pageId: node.id, pageName, index, total, reason: 'No visible layers' }); + continue; + } + + const imageIds = collectImageIdsFromComposition(composition); + figma.ui.postMessage({ + type: 'batch-export-page', + pageId: node.id, + pageName, + composition, + imageIds, + targetDir, + exportFolderName, + index, + total, + }); + const result = await waitForBatchPageAck(node.id); + if (result && result.success) { + summary.exported += 1; + summary.results.push({ pageId: node.id, pageName, status: 'exported' }); + } else { + summary.failed += 1; + summary.results.push({ pageId: node.id, pageName, status: 'failed', error: result && result.error }); + } + } catch (error) { + summary.failed += 1; + const message = error && error.message ? error.message : String(error); + summary.results.push({ pageId: node.id, pageName, status: 'failed', error: message }); + figma.ui.postMessage({ type: 'batch-export:page-failed', pageId: node.id, pageName, index, total, error: message }); + } + } + figma.ui.postMessage({ type: 'batch-export:finished', summary }); } // Initial send @@ -665,6 +747,15 @@ figma.on('selectionchange', () => { figma.ui.onmessage = async (msg) => { if (!msg) return; + if (msg.type === 'batch-export-page:ack') { + const resolver = batchPageAckWaiters.get(msg.pageId); + if (resolver) resolver({ success: msg.success === true, error: msg.error }); + return; + } + if (msg.type === 'batch-export-selection') { + await exportSelectedNodes(msg.nodeIds, msg.targetDir); + return; + } if (msg.type === 'close') { figma.closePlugin(); return; diff --git a/manifest.json b/manifest.json index b3e5abb..b69f2f9 100644 --- a/manifest.json +++ b/manifest.json @@ -14,8 +14,8 @@ "networkAccess": { "allowedDomains": ["none"], "devAllowedDomains": [ - "http://localhost:7788" + "http://localhost:7789" ], "reasoning": "Local development server for live preview" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index fb03a0c..f6e15d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -363,16 +363,6 @@ "@types/node": "*" } }, - "node_modules/@types/cheerio": { - "version": "0.22.35", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz", - "integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1024,48 +1014,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cheerio": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", - "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", - "license": "MIT", - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "encoding-sniffer": "^0.2.1", - "htmlparser2": "^10.0.0", - "parse5": "^7.3.0", - "parse5-htmlparser2-tree-adapter": "^7.1.0", - "parse5-parser-stream": "^7.1.2", - "undici": "^7.12.0", - "whatwg-mimetype": "^4.0.0" - }, - "engines": { - "node": ">=20.18.1" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1578,31 +1526,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding-sniffer": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", - "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", - "license": "MIT", - "dependencies": { - "iconv-lite": "^0.6.3", - "whatwg-encoding": "^3.1.1" - }, - "funding": { - "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" - } - }, - "node_modules/encoding-sniffer/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3155,55 +3078,6 @@ "node": ">=6" } }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", - "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", - "license": "MIT", - "dependencies": { - "domhandler": "^5.0.3", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-parser-stream": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", - "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", - "license": "MIT", - "dependencies": { - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4133,15 +4007,6 @@ "dev": true, "license": "MIT" }, - "node_modules/undici": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", - "integrity": "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -4199,39 +4064,6 @@ "node": ">= 0.8" } }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4454,13 +4286,7 @@ "packages/bridge-pipeline": { "name": "figma-html-bridge", "version": "0.2.0", - "license": "MIT", - "dependencies": { - "cheerio": "^1.1.2" - }, - "devDependencies": { - "@types/cheerio": "^0.22.35" - } + "license": "MIT" } } } diff --git a/package.json b/package.json index e6fe707..e13c53f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "predev": "npm run build:lib", - "dev": "npm run kill-port && nodemon --exec ts-node src/index.ts", + "dev": "nodemon --exec ts-node src/index.ts", "dev:clean": "npm run kill-port && nodemon --exec ts-node src/index.ts --watch src --ext ts,json", "dev:full": "npm run build:lib && concurrently \"npm run watch:lib\" \"npm run dev\" --names \"LIB,SRV\" --prefix-colors \"blue,green\"", "kill-port": "lsof -ti:7788 | xargs kill -9 2>/dev/null || true", diff --git a/src/cli/render-json.ts b/src/cli/render-json.ts index 8bf5740..28ad13a 100644 --- a/src/cli/render-json.ts +++ b/src/cli/render-json.ts @@ -1,7 +1,7 @@ /* Minimal CLI to post a composition JSON to the running backend, so the server generates latest HTML and debug artifacts. - Usage: npm run render-json -- [--server http://localhost:7788] + Usage: npm run render-json -- [--server http://localhost:7789] */ import fs from 'fs'; @@ -13,7 +13,7 @@ type AnyObj = Record; function parseArgs(argv: string[]): { file: string | null; server: string } { let file: string | null = null; - let server = 'http://localhost:7788'; + let server = 'http://localhost:7789'; for (let i = 2; i < argv.length; i++) { const a = argv[i]; @@ -93,7 +93,7 @@ function httpPostJson(urlStr: string, json: AnyObj): Promise<{ status: number; b async function main() { const { file, server } = parseArgs(process.argv); if (!file) { - console.error('Usage: npm run render-json -- [--server http://localhost:7788]'); + console.error('Usage: npm run render-json -- [--server http://localhost:7789]'); process.exit(1); } diff --git a/src/cli/test-fixtures.ts b/src/cli/test-fixtures.ts index dd0e281..080187f 100644 --- a/src/cli/test-fixtures.ts +++ b/src/cli/test-fixtures.ts @@ -6,7 +6,7 @@ Optional meta.json: { thresholdPercent?: number, maxSizeDeltaPercent?: number, notes?: string } Usage: - npm run test-fixtures -- [--fixtures fixtures/figma] [--pattern name-substring] [--threshold 2.5] [--size-tolerance-percent 2.5] [--server http://localhost:7788] [--timeout-ms 15000] + npm run test-fixtures -- [--fixtures fixtures/figma] [--pattern name-substring] [--threshold 2.5] [--size-tolerance-percent 2.5] [--server http://localhost:7789] [--timeout-ms 15000] Preconditions: - Backend running with BRIDGE_DEBUG=1 (so html-render.png is written) @@ -74,7 +74,7 @@ function parseArgs(argv: string[]): CliArgs { let pattern: string | null = null; let thresholdPercent = 3.0; let maxSizeDeltaPercent = 2.5; - let server = 'http://localhost:7788'; + let server = 'http://localhost:7789'; let timeoutMs = 15000; for (let i = 2; i < argv.length; i++) { diff --git a/src/index.ts b/src/index.ts index eac5572..ef4d3eb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs'; -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { processBatch as processImageBatch, ensureUploadsDir, listMissing } from './imageService'; import type { ImageItem } from './imageService'; import { figmaToHtml, normalizeComposition, compositionToIR, normalizeHtml } from 'figma-html-bridge'; @@ -35,7 +35,7 @@ function loadEnvFile() { loadEnvFile(); const app = express(); -const PORT = 7788; +const PORT = 7789; const corsOptions: cors.CorsOptions = { origin: true, @@ -121,6 +121,38 @@ app.get('/api/config', (_req, res) => { }); }); +// The plugin iframe cannot reliably write to arbitrary local directories. On +// Windows, let the local companion server open the native folder picker and +// return the selected absolute path to the plugin UI. +app.post('/api/select-directory', (_req, res) => { + if (process.platform !== 'win32') { + res.status(501).json({ error: 'Native folder selection is currently supported on Windows only' }); + return; + } + + const script = [ + 'Add-Type -AssemblyName System.Windows.Forms', + '$OutputEncoding = [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)', + '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', + '$dialog.Description = "Select a folder for Figma Bridge export"', + '$dialog.ShowNewFolderButton = $true', + 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($dialog.SelectedPath) }', + ].join('; '); + + execFile('powershell.exe', ['-NoProfile', '-STA', '-Command', script], { windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout) => { + if (error) { + res.status(500).json({ error: 'Failed to open folder picker' }); + return; + } + const selectedPath = String(stdout || '').trim(); + if (!selectedPath) { + res.json({ cancelled: true, path: '' }); + return; + } + res.json({ cancelled: false, path: selectedPath }); + }); +}); + function ensureDebugDir() { if (!DEBUG_ENABLED) return; fs.rmSync(DEBUG_LATEST, { recursive: true, force: true }); @@ -203,15 +235,21 @@ const OUTPUT_DIR = path.join(process.cwd(), 'output'); const SVGS_DIR = path.join(process.cwd(), 'temp', 'svgs'); const PREVIEW_ASSETS_DIR = path.join(process.cwd(), 'temp', 'preview'); +function sanitizeExportFolderName(raw: unknown): string { + const value = String(raw || '').trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, '_').replace(/[. ]+$/g, ''); + if (!value || value === '.' || value === '..') return 'page'; + return value.slice(0, 120); +} + let globalSettings = { useOnlineFonts: true }; -function ensureOutputDir() { +function ensureOutputDir(targetDir = OUTPUT_DIR, clean = targetDir === OUTPUT_DIR) { try { - fs.rmSync(OUTPUT_DIR, { recursive: true, force: true }); - fs.mkdirSync(path.join(OUTPUT_DIR, 'images'), { recursive: true }); - fs.mkdirSync(path.join(OUTPUT_DIR, 'svgs'), { recursive: true }); + if (clean) fs.rmSync(targetDir, { recursive: true, force: true }); + fs.mkdirSync(path.join(targetDir, 'images'), { recursive: true }); + fs.mkdirSync(path.join(targetDir, 'svgs'), { recursive: true }); } catch (e) { // ignore: non-critical output dir preparation error; API will still respond } @@ -297,14 +335,14 @@ ${contentHtml} return { html, viewportWidth, viewportHeight }; } -function writeOutputPackage(bodyHtml: string, cssText: string, headLinks: string, imageIds: string[], svgFiles: string[], baseWidth: number, baseHeight: number) { +function writeOutputPackage(bodyHtml: string, cssText: string, headLinks: string, imageIds: string[], svgFiles: string[], baseWidth: number, baseHeight: number, targetDir = OUTPUT_DIR): boolean { try { - ensureOutputDir(); + ensureOutputDir(targetDir, targetDir === OUTPUT_DIR); for (const id of imageIds) { if (typeof id !== 'string') continue; const src = path.join(UPLOAD_DIR, `${id}.png`); if (fs.existsSync(src)) { - const dst = path.join(OUTPUT_DIR, 'images', `${id}.png`); + const dst = path.join(targetDir, 'images', `${id}.png`); try { fs.copyFileSync(src, dst); } catch (e) { @@ -315,7 +353,7 @@ function writeOutputPackage(bodyHtml: string, cssText: string, headLinks: string for (const name of svgFiles || []) { const src = path.join(SVGS_DIR, name); if (fs.existsSync(src)) { - const dst = path.join(OUTPUT_DIR, 'svgs', name); + const dst = path.join(targetDir, 'svgs', name); try { fs.copyFileSync(src, dst); } catch (e) { @@ -325,7 +363,7 @@ function writeOutputPackage(bodyHtml: string, cssText: string, headLinks: string } const { formatCss, formatHtml } = require('./utils/format'); const formattedCss = formatCss(cssText); - fs.writeFileSync(path.join(OUTPUT_DIR, 'styles.css'), formattedCss, 'utf8'); + fs.writeFileSync(path.join(targetDir, 'styles.css'), formattedCss, 'utf8'); const viewportStyles = ` html, body { @@ -371,10 +409,12 @@ function writeOutputPackage(bodyHtml: string, cssText: string, headLinks: string if (post && post.html) rawHtmlDoc = post.html; } catch { } const htmlDoc = formatHtml(rawHtmlDoc); - fs.writeFileSync(path.join(OUTPUT_DIR, 'index.html'), htmlDoc, 'utf8'); + fs.writeFileSync(path.join(targetDir, 'index.html'), htmlDoc, 'utf8'); // output package written + return true; } catch (e) { // ignore: output package write failure will be surfaced via API usage + return false; } } @@ -564,6 +604,11 @@ app.get('/api/languages/:code', (req, res) => { app.post('/api/composition', async (req, res) => { const originalPayload = req.body ?? null; const composition = originalPayload?.composition ?? null; + const exportBaseDir = typeof originalPayload?.exportBaseDir === 'string' ? originalPayload.exportBaseDir.trim() : ''; + const exportFolderName = typeof originalPayload?.exportFolderName === 'string' ? originalPayload.exportFolderName : ''; + const exportDir = exportBaseDir && exportFolderName + ? path.join(path.resolve(exportBaseDir), sanitizeExportFolderName(exportFolderName)) + : undefined; if (!composition) { res.status(400).json({ error: 'composition payload missing' }); @@ -678,7 +723,11 @@ app.post('/api/composition', async (req, res) => { const svgs = Array.isArray((lastResult as any)?.assets?.svgs) ? (lastResult as any).assets.svgs : []; const baseWidth = (lastResult.content as any).baseWidth || renderRes.baseWidth; const baseHeight = (lastResult.content as any).baseHeight || renderRes.baseHeight; - writeOutputPackage(lastResult.content.bodyHtml, lastResult.content.cssText, headLinks2, images, svgs, baseWidth, baseHeight); + const wrotePackage = writeOutputPackage(lastResult.content.bodyHtml, lastResult.content.cssText, headLinks2, images, svgs, baseWidth, baseHeight, exportDir); + if (exportDir && !wrotePackage) { + res.status(500).json({ error: 'Failed to write exported page package' }); + return; + } } catch (e) { // ignore content build failure } diff --git a/ui.html b/ui.html index f967a06..f84ddd7 100644 --- a/ui.html +++ b/ui.html @@ -1,302 +1,255 @@ - + - BRIDGE — PLUGIN + Bridge
-
-
Server CHECKING…
- +
+
BRIDGE
+
正在连接本地服务…
- + + + +
+
批量导出
+
+
0
+
未选择画布对象
+
+
请在 Figma 画布中选择一个或多个 Frame / 图层。
+ +
+