diff --git a/PLAN.md b/PLAN.md index d4c1230a9..e6071ae19 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1368,13 +1368,18 @@ interface CommentPin { #### Implementation order -1. JSX renderer in `@open-pencil/core` (port from figma-use) -2. Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle -3. API key settings — input UI, Stronghold storage (Tauri) / localStorage (browser) -4. Core tools — render, set_fill, set_layout, set_text, snapshot, get_selection -5. Screenshot + diff tools -6. Comment pin system -7. Full tool set (remaining 118 tools) +1. ✅ JSX renderer in `@open-pencil/core` (port from figma-use) +2. ✅ Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle +3. Code panel — "Code" tab in properties panel, JSX export with syntax highlighting + - `sceneNodeToJsx()` in `packages/core/src/render/export-jsx.ts` (reverse of renderer.ts) + - `CodePanel.vue` with Prism.js highlighting, copy button, reka-ui ScrollArea + - Third tab in PropertiesPanel: Design | Code | AI + - Subtree export: full JSX tree for selection, multi-selection separated by blank lines +4. API key settings — input UI, Stronghold storage (Tauri) / localStorage (browser) +5. Core tools — render, set_fill, set_layout, set_text, snapshot, get_selection +6. Screenshot + diff tools +7. Comment pin system +8. Full tool set (remaining 118 tools) #### Validation diff --git a/bun.lock b/bun.lock index 42cdfc204..9ca579f92 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "dedent": "^1.7.1", "fflate": "^0.8.2", "fzstd": "^0.1.1", + "prismjs": "^1.30.0", "reka-ui": "^2.8.2", "tailwindcss": "^4.2.1", "valibot": "^1.2.0", @@ -37,6 +38,7 @@ "@tauri-apps/cli": "^2", "@types/culori": "^4.0.1", "@types/node": "^25.3.2", + "@types/prismjs": "^1.26.6", "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.4", "esbuild": "^0.27.3", @@ -433,6 +435,8 @@ "@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -855,6 +859,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "pug": ["pug@3.0.3", "", { "dependencies": { "pug-code-gen": "^3.0.3", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", "pug-load": "^3.0.0", "pug-parser": "^6.0.0", "pug-runtime": "^3.0.1", "pug-strip-comments": "^2.0.0" } }, "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g=="], diff --git a/components.d.ts b/components.d.ts index 1bc37b06b..fb4c5a1ad 100644 --- a/components.d.ts +++ b/components.d.ts @@ -18,6 +18,7 @@ declare module 'vue' { ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default'] ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default'] ChatPanel: typeof import('./src/components/ChatPanel.vue')['default'] + CodePanel: typeof import('./src/components/CodePanel.vue')['default'] ColorInput: typeof import('./src/components/ColorInput.vue')['default'] ColorPicker: typeof import('./src/components/ColorPicker.vue')['default'] DesignPanel: typeof import('./src/components/DesignPanel.vue')['default'] @@ -45,6 +46,8 @@ declare module 'vue' { IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideCircleDot: typeof import('~icons/lucide/circle-dot')['default'] + IconLucideCode: typeof import('~icons/lucide/code')['default'] + IconLucideCopy: typeof import('~icons/lucide/copy')['default'] IconLucideEye: typeof import('~icons/lucide/eye')['default'] IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default'] IconLucideFile: typeof import('~icons/lucide/file')['default'] diff --git a/package.json b/package.json index d9d2b6891..56939d917 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "dedent": "^1.7.1", "fflate": "^0.8.2", "fzstd": "^0.1.1", + "prismjs": "^1.30.0", "reka-ui": "^2.8.2", "tailwindcss": "^4.2.1", "valibot": "^1.2.0", @@ -55,6 +56,7 @@ "@tauri-apps/cli": "^2", "@types/culori": "^4.0.1", "@types/node": "^25.3.2", + "@types/prismjs": "^1.26.6", "@typescript/native-preview": "^7.0.0-dev.20260228.1", "@vitejs/plugin-vue": "^6.0.4", "esbuild": "^0.27.3", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 534e8bc2f..99ff35c0a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,9 @@ export { type BaseProps, type TextProps, type StyleProps, - type RenderResult + type RenderResult, + sceneNodeToJsx, + selectionToJsx } from './render' export { parseFigmaClipboard, diff --git a/packages/core/src/render/export-jsx.ts b/packages/core/src/render/export-jsx.ts new file mode 100644 index 000000000..d50bb0949 --- /dev/null +++ b/packages/core/src/render/export-jsx.ts @@ -0,0 +1,280 @@ +import { colorToHex } from '../color' + +import type { SceneGraph, SceneNode, Fill, Stroke, Effect, NodeType, Color } from '../scene-graph' + +const NODE_TYPE_TO_TAG: Partial> = { + FRAME: 'Frame', + RECTANGLE: 'Rectangle', + ROUNDED_RECTANGLE: 'Rectangle', + ELLIPSE: 'Ellipse', + TEXT: 'Text', + LINE: 'Line', + STAR: 'Star', + POLYGON: 'Polygon', + VECTOR: 'Vector', + GROUP: 'Group', + SECTION: 'Section', + COMPONENT: 'Component', + COMPONENT_SET: 'Frame', + INSTANCE: 'Frame' +} + +function formatColor(color: Color, opacity = 1): string { + const hex = colorToHex(color) + if (opacity < 1) return hex + Math.round(opacity * 255).toString(16).padStart(2, '0') + return hex +} + +function solidFillColor(fills: Fill[]): string | null { + const visible = fills.filter((f) => f.visible && f.type === 'SOLID') + if (visible.length !== 1) return null + return formatColor(visible[0].color, visible[0].opacity) +} + +function solidStroke(strokes: Stroke[]): { color: string; weight: number } | null { + const visible = strokes.filter((s) => s.visible) + if (visible.length !== 1) return null + return { + color: formatColor(visible[0].color, visible[0].opacity), + weight: visible[0].weight + } +} + +function formatShadow(e: Effect): string | null { + if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null + return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}` +} + +function formatProp(key: string, value: unknown): string { + if (typeof value === 'string') return `${key}="${value}"` + if (typeof value === 'number') return `${key}={${value}}` + if (typeof value === 'boolean') return value ? key : `${key}={false}` + return `${key}={${JSON.stringify(value)}}` +} + +function collectProps(node: SceneNode, graph: SceneGraph): [string, unknown][] { + const props: [string, unknown][] = [] + + if (node.name && node.name !== node.type) { + props.push(['name', node.name]) + } + + if (node.layoutMode !== 'NONE') { + props.push(['flex', node.layoutMode === 'HORIZONTAL' ? 'row' : 'col']) + } + + const isAutoLayout = node.layoutMode !== 'NONE' + const parent = node.parentId ? graph.getNode(node.parentId) : null + const parentIsAutoLayout = parent ? parent.layoutMode !== 'NONE' : false + + if (isAutoLayout) { + const primaryAxis = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height' + const crossAxis = node.layoutMode === 'HORIZONTAL' ? 'height' : 'width' + + if (node.primaryAxisSizing === 'HUG') { + /* hug is default with flex, omit */ + } else if (node.primaryAxisSizing === 'FILL') { + props.push([primaryAxis === 'width' ? 'w' : 'h', 'fill']) + } else { + props.push([primaryAxis === 'width' ? 'w' : 'h', node[primaryAxis]]) + } + + if (node.counterAxisSizing === 'HUG') { + /* hug is default, omit */ + } else if (node.counterAxisSizing === 'FILL') { + props.push([crossAxis === 'width' ? 'w' : 'h', 'fill']) + } else { + props.push([crossAxis === 'width' ? 'w' : 'h', node[crossAxis]]) + } + } else { + if (node.width > 0) props.push(['w', node.width]) + if (node.height > 0) props.push(['h', node.height]) + } + + if (parentIsAutoLayout && node.layoutGrow > 0) { + props.push(['grow', node.layoutGrow]) + } + + if (isAutoLayout && node.itemSpacing > 0) { + props.push(['gap', node.itemSpacing]) + } + + if (isAutoLayout && node.layoutWrap === 'WRAP') { + props.push(['wrap', true]) + if (node.counterAxisSpacing > 0) { + props.push(['rowGap', node.counterAxisSpacing]) + } + } + + if (isAutoLayout) { + if (node.primaryAxisAlign === 'CENTER') props.push(['justify', 'center']) + else if (node.primaryAxisAlign === 'MAX') props.push(['justify', 'end']) + else if (node.primaryAxisAlign === 'SPACE_BETWEEN') props.push(['justify', 'between']) + + if (node.counterAxisAlign === 'CENTER') props.push(['items', 'center']) + else if (node.counterAxisAlign === 'MAX') props.push(['items', 'end']) + else if (node.counterAxisAlign === 'STRETCH') props.push(['items', 'stretch']) + } + + if (isAutoLayout) { + const { paddingTop: pt, paddingRight: pr, paddingBottom: pb, paddingLeft: pl } = node + if (pt > 0 || pr > 0 || pb > 0 || pl > 0) { + if (pt === pr && pr === pb && pb === pl) { + props.push(['p', pt]) + } else if (pt === pb && pl === pr) { + props.push(['py', pt]) + props.push(['px', pl]) + } else { + if (pt > 0) props.push(['pt', pt]) + if (pr > 0) props.push(['pr', pr]) + if (pb > 0) props.push(['pb', pb]) + if (pl > 0) props.push(['pl', pl]) + } + } + } + + const bg = solidFillColor(node.fills) + if (bg) props.push(['bg', bg]) + + const stroke = solidStroke(node.strokes) + if (stroke) { + props.push(['stroke', stroke.color]) + if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight]) + } + + if (node.cornerRadius > 0) { + if (node.independentCorners) { + const { topLeftRadius: tl, topRightRadius: tr, bottomRightRadius: br, bottomLeftRadius: bl } = node + if (tl === tr && tr === br && br === bl) { + props.push(['rounded', tl]) + } else { + if (tl > 0) props.push(['roundedTL', tl]) + if (tr > 0) props.push(['roundedTR', tr]) + if (br > 0) props.push(['roundedBR', br]) + if (bl > 0) props.push(['roundedBL', bl]) + } + } else { + props.push(['rounded', node.cornerRadius]) + } + } + + if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing]) + if (node.opacity < 1) props.push(['opacity', Math.round(node.opacity * 100) / 100]) + if (node.rotation !== 0) props.push(['rotate', Math.round(node.rotation * 100) / 100]) + if (node.blendMode !== 'PASS_THROUGH' && node.blendMode !== 'NORMAL') { + props.push(['blendMode', node.blendMode.toLowerCase()]) + } + if (node.clipsContent) props.push(['overflow', 'hidden']) + + for (const effect of node.effects) { + if (!effect.visible) continue + if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { + const shadow = formatShadow(effect) + if (shadow) props.push(['shadow', shadow]) + } else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') { + props.push(['blur', effect.radius]) + } + } + + if (node.type === 'TEXT') { + if (node.fontSize !== 14) props.push(['size', node.fontSize]) + if (node.fontFamily && node.fontFamily !== 'Inter') props.push(['font', node.fontFamily]) + if (node.fontWeight !== 400) { + if (node.fontWeight === 700) props.push(['weight', 'bold']) + else if (node.fontWeight === 500) props.push(['weight', 'medium']) + else props.push(['weight', node.fontWeight]) + } + if (node.textAlignHorizontal !== 'LEFT') { + props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) + } + const textColor = solidFillColor(node.fills) + if (textColor) { + const bgIdx = props.findIndex(([k]) => k === 'bg') + if (bgIdx !== -1) props.splice(bgIdx, 1) + props.push(['color', textColor]) + } + } + + if (node.type === 'STAR') { + if (node.pointCount !== 5) props.push(['points', node.pointCount]) + if (node.starInnerRadius !== 0.382) props.push(['innerRadius', node.starInnerRadius]) + } + if (node.type === 'POLYGON' && node.pointCount !== 3) { + props.push(['points', node.pointCount]) + } + + return props +} + +function nodeToJsx( + node: SceneNode, + graph: SceneGraph, + indent: number +): string { + const tag = NODE_TYPE_TO_TAG[node.type] + if (!tag) return '' + + const prefix = ' '.repeat(indent) + const props = collectProps(node, graph) + const propsStr = props.map(([k, v]) => formatProp(k, v)).join(' ') + const opening = propsStr ? `<${tag} ${propsStr}` : `<${tag}` + + const children = graph.getChildren(node.id) + const isText = node.type === 'TEXT' + + if (isText) { + const text = node.text + if (!text) return `${prefix}${opening} />` + const escaped = text.replace(/[{}<>&]/g, (c) => + c === '{' ? '{' : c === '}' ? '}' : c === '<' ? '<' : c === '>' ? '>' : '&' + ) + if (!escaped.includes('\n')) { + return `${prefix}${opening}>${escaped}` + } + const lines = escaped.split('\n') + const parts = [ + `${prefix}${opening}>`, + ...lines.map((l) => `${prefix} ${l}`), + `${prefix}` + ] + return parts.join('\n') + } + + if (children.length === 0) { + return `${prefix}${opening} />` + } + + const childJsx = children + .filter((c) => c.visible) + .map((c) => nodeToJsx(c, graph, indent + 1)) + .filter(Boolean) + + if (childJsx.length === 0) { + return `${prefix}${opening} />` + } + + return [ + `${prefix}${opening}>`, + ...childJsx, + `${prefix}` + ].join('\n') +} + +export function sceneNodeToJsx( + nodeId: string, + graph: SceneGraph +): string { + const node = graph.getNode(nodeId) + if (!node) return '' + return nodeToJsx(node, graph, 0) +} + +export function selectionToJsx( + nodeIds: string[], + graph: SceneGraph +): string { + return nodeIds + .map((id) => sceneNodeToJsx(id, graph)) + .filter(Boolean) + .join('\n\n') +} diff --git a/packages/core/src/render/index.ts b/packages/core/src/render/index.ts index ad854a1b1..40537c409 100644 --- a/packages/core/src/render/index.ts +++ b/packages/core/src/render/index.ts @@ -22,3 +22,5 @@ export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeN export { renderTree, type RenderResult } from './renderer' export { renderJsx, renderTreeNode, buildComponent } from './render-jsx' + +export { sceneNodeToJsx, selectionToJsx } from './export-jsx' diff --git a/src/components/CodePanel.vue b/src/components/CodePanel.vue new file mode 100644 index 000000000..0975544ec --- /dev/null +++ b/src/components/CodePanel.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index dafdefd27..4b5adabe1 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -5,6 +5,7 @@ import { useAIChat } from '@/composables/use-chat' import { useEditorStore } from '@/stores/editor' import ChatPanel from './ChatPanel.vue' +import CodePanel from './CodePanel.vue' import DesignPanel from './DesignPanel.vue' const store = useEditorStore() @@ -24,6 +25,13 @@ const { activeTab } = useAIChat(store) > Design + + + Code + + + + + { + test('basic rectangle', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'Box', + width: 100, + height: 50 + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toBe('') + }) + + test('rectangle with fill and rounded corners', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'Card', + width: 320, + height: 200, + fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }], + cornerRadius: 16 + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('w={320}') + expect(jsx).toContain('h={200}') + expect(jsx).toContain('bg="#FFFFFF"') + expect(jsx).toContain('rounded={16}') + expect(jsx).toContain('') + }) + + test('text node', () => { + const graph = makeGraph() + const node = graph.createNode('TEXT', pageId(graph), { + name: 'Title', + width: 200, + height: 24, + text: 'Hello World', + fontSize: 18, + fontWeight: 700, + fills: [ + { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } + ] + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('Hello World') + }) + + test('frame with auto-layout', () => { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Row', + width: 400, + height: 100, + layoutMode: 'HORIZONTAL', + itemSpacing: 16, + paddingTop: 12, + paddingRight: 12, + paddingBottom: 12, + paddingLeft: 12, + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'HUG' + }) + const jsx = sceneNodeToJsx(frame.id, graph) + expect(jsx).toContain('flex="row"') + expect(jsx).toContain('gap={16}') + expect(jsx).toContain('p={12}') + expect(jsx).toContain('w={400}') + }) + + test('frame with children', () => { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Card', + width: 320, + height: 200, + layoutMode: 'VERTICAL', + itemSpacing: 8, + primaryAxisSizing: 'HUG', + counterAxisSizing: 'FIXED' + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Image', + width: 320, + height: 120 + }) + graph.createNode('TEXT', frame.id, { + name: 'Title', + width: 200, + height: 20, + text: 'Card Title', + fontSize: 16, + fontWeight: 700, + fills: [ + { type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } + ] + }) + + const jsx = sceneNodeToJsx(frame.id, graph) + expect(jsx).toContain('Card Title') + expect(jsx).toContain('') + }) + + test('asymmetric padding', () => { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Box', + width: 100, + height: 100, + layoutMode: 'HORIZONTAL', + paddingTop: 8, + paddingRight: 16, + paddingBottom: 8, + paddingLeft: 16, + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + const jsx = sceneNodeToJsx(frame.id, graph) + expect(jsx).toContain('py={8}') + expect(jsx).toContain('px={16}') + expect(jsx).not.toContain('pt=') + expect(jsx).not.toContain('pb=') + }) + + test('independent corners', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'Pill', + width: 100, + height: 40, + cornerRadius: 20, + independentCorners: true, + topLeftRadius: 20, + topRightRadius: 0, + bottomRightRadius: 0, + bottomLeftRadius: 20 + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('roundedTL={20}') + expect(jsx).toContain('roundedBL={20}') + expect(jsx).not.toContain('roundedTR=') + expect(jsx).not.toContain('roundedBR=') + }) + + test('opacity and rotation', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 50, + height: 50, + opacity: 0.5, + rotation: 45 + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('opacity={0.5}') + expect(jsx).toContain('rotate={45}') + }) + + test('stroke', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + strokes: [ + { color: { r: 1, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'INSIDE' as const } + ] + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('stroke="#FF0000"') + expect(jsx).toContain('strokeWidth={2}') + }) + + test('hidden children are excluded', () => { + const graph = makeGraph() + const frame = graph.createNode('FRAME', pageId(graph), { + name: 'Parent', + width: 100, + height: 100 + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Visible', + width: 50, + height: 50 + }) + graph.createNode('RECTANGLE', frame.id, { + name: 'Hidden', + width: 50, + height: 50, + visible: false + }) + const jsx = sceneNodeToJsx(frame.id, graph) + expect(jsx).toContain('Visible') + expect(jsx).not.toContain('Hidden') + }) + + test('empty text node renders self-closing', () => { + const graph = makeGraph() + const node = graph.createNode('TEXT', pageId(graph), { + name: 'Empty', + width: 100, + height: 20, + text: '' + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('/>') + expect(jsx).not.toContain('') + }) + + test('omits default values', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + opacity: 1, + rotation: 0, + cornerRadius: 0 + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).not.toContain('opacity') + expect(jsx).not.toContain('rotate') + expect(jsx).not.toContain('rounded') + }) + + test('effects: shadow and blur', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + width: 100, + height: 100, + effects: [ + { + type: 'DROP_SHADOW', + color: { r: 0, g: 0, b: 0, a: 0.25 }, + offset: { x: 0, y: 4 }, + radius: 8, + spread: 0, + visible: true + }, + { + type: 'LAYER_BLUR', + color: { r: 0, g: 0, b: 0, a: 0 }, + offset: { x: 0, y: 0 }, + radius: 4, + spread: 0, + visible: true + } + ] + }) + const jsx = sceneNodeToJsx(node.id, graph) + expect(jsx).toContain('shadow="0 4 8') + expect(jsx).toContain('blur={4}') + }) +}) + +describe('selectionToJsx', () => { + test('multiple nodes separated by blank lines', () => { + const graph = makeGraph() + const a = graph.createNode('RECTANGLE', pageId(graph), { + name: 'A', + width: 10, + height: 10 + }) + const b = graph.createNode('ELLIPSE', pageId(graph), { + name: 'B', + width: 20, + height: 20 + }) + const jsx = selectionToJsx([a.id, b.id], graph) + expect(jsx).toContain('