Add Code tab with JSX export and syntax highlighting
- sceneNodeToJsx() in @open-pencil/core: converts SceneNode subtree to JSX - CodePanel.vue with Prism.js highlighting, line numbers, copy button - Third tab in properties panel: Design | Code | AI - 14 new tests covering shapes, text, layout, effects, multi-selection
This commit is contained in:
parent
bf8636c677
commit
c59d5fc397
19
PLAN.md
19
PLAN.md
|
|
@ -1368,13 +1368,18 @@ interface CommentPin {
|
||||||
|
|
||||||
#### Implementation order
|
#### Implementation order
|
||||||
|
|
||||||
1. JSX renderer in `@open-pencil/core` (port from figma-use)
|
1. ✅ JSX renderer in `@open-pencil/core` (port from figma-use)
|
||||||
2. Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle
|
2. ✅ Chat panel UI — `ChatPanel.vue`, `useChat()`, message list, tool timeline, `⌘J` toggle
|
||||||
3. API key settings — input UI, Stronghold storage (Tauri) / localStorage (browser)
|
3. Code panel — "Code" tab in properties panel, JSX export with syntax highlighting
|
||||||
4. Core tools — render, set_fill, set_layout, set_text, snapshot, get_selection
|
- `sceneNodeToJsx()` in `packages/core/src/render/export-jsx.ts` (reverse of renderer.ts)
|
||||||
5. Screenshot + diff tools
|
- `CodePanel.vue` with Prism.js highlighting, copy button, reka-ui ScrollArea
|
||||||
6. Comment pin system
|
- Third tab in PropertiesPanel: Design | Code | AI
|
||||||
7. Full tool set (remaining 118 tools)
|
- 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
|
#### Validation
|
||||||
|
|
||||||
|
|
|
||||||
6
bun.lock
6
bun.lock
|
|
@ -24,6 +24,7 @@
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"reka-ui": "^2.8.2",
|
"reka-ui": "^2.8.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"valibot": "^1.2.0",
|
"valibot": "^1.2.0",
|
||||||
|
|
@ -37,6 +38,7 @@
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"esbuild": "^0.27.3",
|
"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/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/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|
|
||||||
3
components.d.ts
vendored
3
components.d.ts
vendored
|
|
@ -18,6 +18,7 @@ declare module 'vue' {
|
||||||
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
|
||||||
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
|
||||||
ChatPanel: typeof import('./src/components/ChatPanel.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']
|
ColorInput: typeof import('./src/components/ColorInput.vue')['default']
|
||||||
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
|
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
|
||||||
DesignPanel: typeof import('./src/components/DesignPanel.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']
|
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
|
||||||
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
|
||||||
IconLucideCircleDot: typeof import('~icons/lucide/circle-dot')['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']
|
IconLucideEye: typeof import('~icons/lucide/eye')['default']
|
||||||
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
|
IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default']
|
||||||
IconLucideFile: typeof import('~icons/lucide/file')['default']
|
IconLucideFile: typeof import('~icons/lucide/file')['default']
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
"dedent": "^1.7.1",
|
"dedent": "^1.7.1",
|
||||||
"fflate": "^0.8.2",
|
"fflate": "^0.8.2",
|
||||||
"fzstd": "^0.1.1",
|
"fzstd": "^0.1.1",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
"reka-ui": "^2.8.2",
|
"reka-ui": "^2.8.2",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"valibot": "^1.2.0",
|
"valibot": "^1.2.0",
|
||||||
|
|
@ -55,6 +56,7 @@
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
|
"@types/prismjs": "^1.26.6",
|
||||||
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
"@typescript/native-preview": "^7.0.0-dev.20260228.1",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"esbuild": "^0.27.3",
|
"esbuild": "^0.27.3",
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,9 @@ export {
|
||||||
type BaseProps,
|
type BaseProps,
|
||||||
type TextProps,
|
type TextProps,
|
||||||
type StyleProps,
|
type StyleProps,
|
||||||
type RenderResult
|
type RenderResult,
|
||||||
|
sceneNodeToJsx,
|
||||||
|
selectionToJsx
|
||||||
} from './render'
|
} from './render'
|
||||||
export {
|
export {
|
||||||
parseFigmaClipboard,
|
parseFigmaClipboard,
|
||||||
|
|
|
||||||
280
packages/core/src/render/export-jsx.ts
Normal file
280
packages/core/src/render/export-jsx.ts
Normal file
|
|
@ -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<Record<NodeType, string>> = {
|
||||||
|
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}</${tag}>`
|
||||||
|
}
|
||||||
|
const lines = escaped.split('\n')
|
||||||
|
const parts = [
|
||||||
|
`${prefix}${opening}>`,
|
||||||
|
...lines.map((l) => `${prefix} ${l}`),
|
||||||
|
`${prefix}</${tag}>`
|
||||||
|
]
|
||||||
|
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}</${tag}>`
|
||||||
|
].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')
|
||||||
|
}
|
||||||
|
|
@ -22,3 +22,5 @@ export { type TreeNode, type BaseProps, type TextProps, type StyleProps, isTreeN
|
||||||
export { renderTree, type RenderResult } from './renderer'
|
export { renderTree, type RenderResult } from './renderer'
|
||||||
|
|
||||||
export { renderJsx, renderTreeNode, buildComponent } from './render-jsx'
|
export { renderJsx, renderTreeNode, buildComponent } from './render-jsx'
|
||||||
|
|
||||||
|
export { sceneNodeToJsx, selectionToJsx } from './export-jsx'
|
||||||
|
|
|
||||||
106
src/components/CodePanel.vue
Normal file
106
src/components/CodePanel.vue
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Prism from 'prismjs'
|
||||||
|
import 'prismjs/components/prism-jsx'
|
||||||
|
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { selectionToJsx } from '@open-pencil/core'
|
||||||
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const jsxCode = computed(() => {
|
||||||
|
void store.state.sceneVersion
|
||||||
|
const ids = [...store.state.selectedIds]
|
||||||
|
if (ids.length === 0) return ''
|
||||||
|
return selectionToJsx(ids, store.graph)
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlightedLines = computed(() => {
|
||||||
|
if (!jsxCode.value) return []
|
||||||
|
const grammar = Prism.languages.jsx ?? Prism.languages.javascript
|
||||||
|
return jsxCode.value
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => Prism.highlight(line, grammar, 'jsx'))
|
||||||
|
})
|
||||||
|
|
||||||
|
let copyTimeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
|
||||||
|
function copyCode() {
|
||||||
|
navigator.clipboard.writeText(jsxCode.value)
|
||||||
|
copied.value = true
|
||||||
|
clearTimeout(copyTimeout)
|
||||||
|
copyTimeout = setTimeout(() => (copied.value = false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(jsxCode, () => {
|
||||||
|
copied.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!jsxCode" class="flex flex-1 items-center justify-center px-4 text-center">
|
||||||
|
<span class="text-xs text-muted">Select a layer to see its JSX code</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<span class="text-[11px] text-muted">JSX</span>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
|
||||||
|
@click="copyCode"
|
||||||
|
>
|
||||||
|
<icon-lucide-check v-if="copied" class="size-3 text-green-400" />
|
||||||
|
<icon-lucide-copy v-else class="size-3" />
|
||||||
|
{{ copied ? 'Copied' : 'Copy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollAreaRoot class="min-h-0 flex-1">
|
||||||
|
<ScrollAreaViewport class="size-full">
|
||||||
|
<div class="p-3">
|
||||||
|
<div
|
||||||
|
v-for="(html, i) in highlightedLines"
|
||||||
|
:key="i"
|
||||||
|
class="flex text-xs leading-5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mr-3 shrink-0 select-none text-right text-muted/40"
|
||||||
|
style="min-width: 1.5em"
|
||||||
|
>{{ i + 1 }}</span>
|
||||||
|
<pre class="m-0 min-w-0 flex-1 whitespace-pre-wrap break-words"><code v-html="html" /></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollAreaViewport>
|
||||||
|
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none select-none p-px">
|
||||||
|
<ScrollAreaThumb class="relative flex-1 rounded-full bg-white/10" />
|
||||||
|
</ScrollAreaScrollbar>
|
||||||
|
</ScrollAreaRoot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.token.tag {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
.token.attr-name {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.token.attr-value,
|
||||||
|
.token.string {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
.token.number {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.token.punctuation {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
.token.boolean {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.token.keyword {
|
||||||
|
color: #c4b5fd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -5,6 +5,7 @@ import { useAIChat } from '@/composables/use-chat'
|
||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
|
|
||||||
import ChatPanel from './ChatPanel.vue'
|
import ChatPanel from './ChatPanel.vue'
|
||||||
|
import CodePanel from './CodePanel.vue'
|
||||||
import DesignPanel from './DesignPanel.vue'
|
import DesignPanel from './DesignPanel.vue'
|
||||||
|
|
||||||
const store = useEditorStore()
|
const store = useEditorStore()
|
||||||
|
|
@ -24,6 +25,13 @@ const { activeTab } = useAIChat(store)
|
||||||
>
|
>
|
||||||
Design
|
Design
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="code"
|
||||||
|
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
|
||||||
|
>
|
||||||
|
<icon-lucide-code class="size-3" />
|
||||||
|
Code
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="ai"
|
value="ai"
|
||||||
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
|
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
|
||||||
|
|
@ -48,6 +56,15 @@ const { activeTab } = useAIChat(store)
|
||||||
<DesignPanel />
|
<DesignPanel />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="code"
|
||||||
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
|
:force-mount="true"
|
||||||
|
:hidden="activeTab !== 'code'"
|
||||||
|
>
|
||||||
|
<CodePanel />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent
|
||||||
value="ai"
|
value="ai"
|
||||||
class="flex min-h-0 flex-1 flex-col"
|
class="flex min-h-0 flex-1 flex-col"
|
||||||
|
|
|
||||||
|
|
@ -77,5 +77,3 @@ export async function loadFont(family: string, style = 'Regular'): Promise<Array
|
||||||
|
|
||||||
return loadFontCore(family, style)
|
return loadFontCore(family, style)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
293
tests/engine/export-jsx.test.ts
Normal file
293
tests/engine/export-jsx.test.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import { SceneGraph, sceneNodeToJsx, selectionToJsx } from '@open-pencil/core'
|
||||||
|
|
||||||
|
function makeGraph() {
|
||||||
|
const graph = new SceneGraph()
|
||||||
|
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
|
||||||
|
return graph
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageId(graph: SceneGraph) {
|
||||||
|
return graph.getPages()[0].id
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sceneNodeToJsx', () => {
|
||||||
|
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('<Rectangle name="Box" w={100} h={50} />')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('<Rectangle')
|
||||||
|
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('<Text')
|
||||||
|
expect(jsx).toContain('size={18}')
|
||||||
|
expect(jsx).toContain('weight="bold"')
|
||||||
|
expect(jsx).toContain('color="#000000"')
|
||||||
|
expect(jsx).toContain('>Hello World</Text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('<Frame')
|
||||||
|
expect(jsx).toContain('flex="col"')
|
||||||
|
expect(jsx).toContain('gap={8}')
|
||||||
|
expect(jsx).toContain(' <Rectangle')
|
||||||
|
expect(jsx).toContain(' <Text')
|
||||||
|
expect(jsx).toContain('>Card Title</Text>')
|
||||||
|
expect(jsx).toContain('</Frame>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('</Text>')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('<Rectangle')
|
||||||
|
expect(jsx).toContain('<Ellipse')
|
||||||
|
expect(jsx).toContain('\n\n')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue