feat: JSX reference, multi-root JSX, prompts as markdown files
- Move CODEGEN_PROMPT to codegen.md, loaded via raw-md bundler plugin - Add JSX_REFERENCE as jsx-reference.md with full prop/tag/example docs - Copy JSX Reference button (book icon) in Code panel header - Multi-root JSX: try parsing as-is, wrap in fragment on failure - Component and Instance tag aliases in JSX renderer - renderJSX returns RenderResult[] to support fragments - raw-md plugin for both tsdown and Vite Co-authored-by: sld0Ant <sld0Ant@users.noreply.github.com>
This commit is contained in:
parent
d692953d62
commit
1d5558ddc3
|
|
@ -11,22 +11,32 @@ import type { SceneGraph } from '../scene-graph'
|
|||
* Works in both Node/Bun and the browser (no native bindings).
|
||||
*/
|
||||
export function buildComponent(jsxString: string): () => unknown {
|
||||
const code = `
|
||||
const trimmed = jsxString.trim()
|
||||
|
||||
const aliases = `
|
||||
const __h = React.createElement
|
||||
const __frag = ''
|
||||
const Frame = 'frame', Text = 'text', Rectangle = 'rectangle', Ellipse = 'ellipse'
|
||||
const Line = 'line', Star = 'star', Polygon = 'polygon', Vector = 'vector'
|
||||
const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle'
|
||||
const Component = 'component', Instance = 'frame'
|
||||
const Icon = 'icon'
|
||||
return function Component() { return ${jsxString.trim()} }
|
||||
`
|
||||
|
||||
const result = transform(code, {
|
||||
transforms: ['typescript', 'jsx'],
|
||||
const opts = {
|
||||
transforms: ['typescript', 'jsx'] as Array<'typescript' | 'jsx'>,
|
||||
jsxPragma: '__h',
|
||||
jsxFragmentPragma: '__frag',
|
||||
production: true
|
||||
})
|
||||
}
|
||||
|
||||
return new Function('React', result.code)(React) as () => unknown
|
||||
let code: string
|
||||
try {
|
||||
code = transform(`${aliases}\nreturn function __render() { return ${trimmed} }`, opts).code
|
||||
} catch {
|
||||
code = transform(`${aliases}\nreturn function __render() { return <>${trimmed}</> }`, opts).code
|
||||
}
|
||||
|
||||
return new Function('React', code)(React) as () => unknown
|
||||
}
|
||||
|
||||
interface RenderJSXOptions {
|
||||
|
|
@ -43,7 +53,7 @@ export async function renderJSX(
|
|||
graph: SceneGraph,
|
||||
jsxString: string,
|
||||
options?: RenderJSXOptions
|
||||
): Promise<RenderResult> {
|
||||
): Promise<RenderResult[]> {
|
||||
const Component = buildComponent(jsxString)
|
||||
const element = React.createElement(Component, null)
|
||||
const tree = resolveToTree(element)
|
||||
|
|
@ -52,7 +62,19 @@ export async function renderJSX(
|
|||
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
|
||||
}
|
||||
|
||||
return renderTree(graph, tree, options)
|
||||
if (tree.type === '' && tree.children.length > 0) {
|
||||
const results: RenderResult[] = []
|
||||
for (const child of tree.children) {
|
||||
if (typeof child === 'string') continue
|
||||
results.push(await renderTree(graph, child, options))
|
||||
}
|
||||
if (results.length === 0) {
|
||||
throw new Error('JSX must return a Figma element (Frame, Text, etc)')
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
return [await renderTree(graph, tree, options)]
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
5
packages/core/src/global.d.ts
vendored
5
packages/core/src/global.d.ts
vendored
|
|
@ -17,3 +17,8 @@ interface Uint8ArrayConstructor {
|
|||
interface Uint8Array {
|
||||
toBase64(options?: { alphabet?: 'base64' | 'base64url' }): string
|
||||
}
|
||||
|
||||
declare module '*.md' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -395,7 +395,8 @@ export {
|
|||
export * from './io'
|
||||
export * from './lint'
|
||||
|
||||
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
|
||||
export { default as CODEGEN_PROMPT } from './tools/prompts/codegen.md'
|
||||
export { default as JSX_REFERENCE } from './tools/prompts/jsx-reference.md'
|
||||
export {
|
||||
setPexelsApiKey,
|
||||
setUnsplashAccessKey,
|
||||
|
|
|
|||
|
|
@ -85,11 +85,12 @@ export const render = defineTool({
|
|||
}
|
||||
}
|
||||
|
||||
const result = await renderJSX(figma.graph, args.jsx, {
|
||||
const results = await renderJSX(figma.graph, args.jsx, {
|
||||
parentId,
|
||||
x: args.x,
|
||||
y: args.y
|
||||
})
|
||||
const result = results[0]
|
||||
|
||||
if (args.replace_id && replaceIndex >= 0) {
|
||||
figma.graph.reorderChild(result.id, parentId, replaceIndex)
|
||||
|
|
@ -98,7 +99,15 @@ export const render = defineTool({
|
|||
figma.graph.reorderChild(result.id, parentId, args.insert_index)
|
||||
}
|
||||
|
||||
return { id: result.id, name: result.name, type: result.type, children: result.childIds }
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
type: result.type,
|
||||
children: result.childIds,
|
||||
...(results.length > 1
|
||||
? { siblings: results.slice(1).map((r) => ({ id: r.id, name: r.name, type: r.type })) }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
export const CODEGEN_PROMPT = `# Design to Code
|
||||
# Design to Code
|
||||
|
||||
You convert Figma designs into production frontend code. You have full access to the design document through tools. Never guess — always read the actual design data.
|
||||
|
||||
> Note: Auto-layout and visual property names match the JSX props in the render system. See \`describe\` tool output for semantic role and layout analysis of any node.
|
||||
> Note: Auto-layout and visual property names match the JSX props in the render system. See `describe` tool output for semantic role and layout analysis of any node.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ You convert Figma designs into production frontend code. You have full access to
|
|||
|
||||
Understand the full picture before writing any code.
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
get_page_tree → document structure, all top-level frames
|
||||
get_components → reusable components defined by the designer
|
||||
list_variables → design tokens (colors, numbers, strings, booleans)
|
||||
|
|
@ -18,9 +18,10 @@ list_collections → variable collections and modes (light/dark, density,
|
|||
analyze_colors → color palette, frequencies, which colors use variables
|
||||
analyze_typography → font stacks, sizes, weights in use
|
||||
analyze_spacing → gap and padding values, grid compliance
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
After this step you should know:
|
||||
|
||||
- How many screens/pages the design has
|
||||
- What components exist
|
||||
- What the token system looks like (or if there is none)
|
||||
|
|
@ -31,110 +32,114 @@ After this step you should know:
|
|||
|
||||
Identify the component architecture.
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
analyze_clusters → find repeated visual patterns that should be components
|
||||
describe (per node) → semantic role, layout direction, visual properties, issues
|
||||
get_jsx (per node) → structural JSX to understand nesting and layout
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Build a component map:
|
||||
|
||||
- **Screens** — top-level frames that represent pages/views
|
||||
- **Components** — COMPONENT/COMPONENT_SET nodes or repeated patterns from analyze_clusters
|
||||
- **Primitives** — leaf elements (text, icons, dividers) that don't need their own component file
|
||||
|
||||
For each component determine:
|
||||
|
||||
- **Props** — what content varies between instances (text, color, icon, visibility)
|
||||
- **Variants** — if the component has multiple states (default/hover/active, small/medium/large)
|
||||
- **Slots** — where child content is injected
|
||||
|
||||
### Step 3 — Extract tokens
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
list_variables → all variables with values per mode
|
||||
list_collections → collection structure and mode names
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Map design variables to code tokens. The approach depends on the target stack:
|
||||
|
||||
#### Tailwind projects
|
||||
|
||||
Do NOT create CSS custom properties for font sizes, font weights, spacing, or border radius — Tailwind has its own system for these. Use Tailwind utility classes directly:
|
||||
- Font sizes → \`text-[13px]\`, \`text-sm\`, \`text-base\`, etc.
|
||||
- Font weights → \`font-bold\`, \`font-medium\`, \`font-[600]\`
|
||||
- Spacing → \`gap-3\`, \`p-4\`, \`px-5\`, \`py-[14px]\`, or arbitrary \`gap-[12px]\`
|
||||
- Border radius → \`rounded-xl\`, \`rounded-[14px]\`, \`rounded-full\`
|
||||
|
||||
Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like \`--font-bold\`, \`--text-sm\`, \`--radius-lg\`):
|
||||
- Font sizes → `text-[13px]`, `text-sm`, `text-base`, etc.
|
||||
- Font weights → `font-bold`, `font-medium`, `font-[600]`
|
||||
- Spacing → `gap-3`, `p-4`, `px-5`, `py-[14px]`, or arbitrary `gap-[12px]`
|
||||
- Border radius → `rounded-xl`, `rounded-[14px]`, `rounded-full`
|
||||
|
||||
\`\`\`css
|
||||
Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like `--font-bold`, `--text-sm`, `--radius-lg`):
|
||||
|
||||
```css
|
||||
:root {
|
||||
--movie-bg: #0F0F1A;
|
||||
--movie-surface: #1A1A2E;
|
||||
--movie-accent: #7C3AED;
|
||||
--movie-text: #FFFFFF;
|
||||
--movie-text-dim: #FFFFFF80;
|
||||
--movie-bg: #0f0f1a;
|
||||
--movie-surface: #1a1a2e;
|
||||
--movie-accent: #7c3aed;
|
||||
--movie-text: #ffffff;
|
||||
--movie-text-dim: #ffffff80;
|
||||
}
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Reference in Tailwind classes: \`bg-[var(--movie-bg)]\`, \`text-[var(--movie-text)]\`
|
||||
Reference in Tailwind classes: `bg-[var(--movie-bg)]`, `text-[var(--movie-text)]`
|
||||
|
||||
#### CSS Modules / plain CSS projects
|
||||
|
||||
Create CSS custom properties for all token categories (colors, spacing, typography, radius). Use a project-specific prefix to avoid collisions:
|
||||
|
||||
\`\`\`css
|
||||
```css
|
||||
:root {
|
||||
--app-color-bg: #0F0F1A;
|
||||
--app-color-bg: #0f0f1a;
|
||||
--app-space-sm: 4px;
|
||||
--app-text-sm: 12px;
|
||||
--app-radius-md: 8px;
|
||||
}
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
#### No design variables in the file
|
||||
|
||||
If the design has no Figma variables, extract implicit tokens from \`analyze_colors\` and \`analyze_typography\` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else.
|
||||
If the design has no Figma variables, extract implicit tokens from `analyze_colors` and `analyze_typography` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else.
|
||||
|
||||
#### Multi-mode collections (light/dark)
|
||||
|
||||
- Generate token values for each mode
|
||||
- Use CSS custom properties with class-based switching (\`.dark { ... }\`)
|
||||
- Use CSS custom properties with class-based switching (`.dark { ... }`)
|
||||
|
||||
### Step 4 — Generate code
|
||||
|
||||
For each component, bottom-up (primitives first, then composites, then screens):
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
get_jsx id=<component_id> → read structure
|
||||
describe id=<component_id> → understand semantic role
|
||||
export_svg ids=[<icon_ids>] → extract vector assets
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- One component per file
|
||||
- Component name comes from the Figma node name, converted to PascalCase
|
||||
- Props interface reflects the variable content identified in Step 2
|
||||
- Use design tokens from Step 3 for semantic colors
|
||||
- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in \`var()\` indirection
|
||||
- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in `var()` indirection
|
||||
- Match measurements exactly: font sizes, spacing, border radii, colors
|
||||
- Use auto-layout data to determine flex direction, gap, padding, alignment
|
||||
- Preserve text direction and container flow direction separately when the design uses RTL.
|
||||
- Absolute positioning only when \`layoutPositioning\` is \`ABSOLUTE\` or layout mode is \`NONE\`
|
||||
- If a node has \`clipsContent: true\`, use \`overflow: hidden\`
|
||||
- Absolute positioning only when `layoutPositioning` is `ABSOLUTE` or layout mode is `NONE`
|
||||
- If a node has `clipsContent: true`, use `overflow: hidden`
|
||||
- Text nodes: preserve font family, size, weight, line height, letter spacing, alignment
|
||||
- Images/illustrations: use \`export_svg\` for vectors, placeholder \`<img>\` for raster
|
||||
- Images/illustrations: use `export_svg` for vectors, placeholder `<img>` for raster
|
||||
|
||||
**Interactive states:**
|
||||
|
||||
Figma designs rarely include hover/active/focus states unless the component has explicit variants for them. Always add sensible interactive feedback to clickable elements:
|
||||
|
||||
- **Buttons (primary):** \`hover:brightness-110 active:brightness-90 transition-all\`
|
||||
- **Buttons (secondary/ghost):** \`hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors\`
|
||||
- **Icon buttons:** \`hover:bg-white/[0.15] active:scale-95 transition-all\`
|
||||
- **Cards/list items (if clickable):** \`hover:bg-white/[0.04] transition-colors\`
|
||||
- **Links/text buttons:** \`hover:underline\` or \`hover:opacity-80\`
|
||||
- **All interactive elements:** add \`cursor-pointer\` and \`select-none\`
|
||||
- **Focus visible:** add \`focus-visible:ring-2 focus-visible:ring-offset-2\` with accent color for accessibility
|
||||
- **Buttons (primary):** `hover:brightness-110 active:brightness-90 transition-all`
|
||||
- **Buttons (secondary/ghost):** `hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors`
|
||||
- **Icon buttons:** `hover:bg-white/[0.15] active:scale-95 transition-all`
|
||||
- **Cards/list items (if clickable):** `hover:bg-white/[0.04] transition-colors`
|
||||
- **Links/text buttons:** `hover:underline` or `hover:opacity-80`
|
||||
- **All interactive elements:** add `cursor-pointer` and `select-none`
|
||||
- **Focus visible:** add `focus-visible:ring-2 focus-visible:ring-offset-2` with accent color for accessibility
|
||||
|
||||
If the design HAS explicit hover/active variants (COMPONENT_SET with state property), use those exact styles instead of defaults above.
|
||||
|
||||
|
|
@ -142,12 +147,13 @@ If the design HAS explicit hover/active variants (COMPONENT_SET with state prope
|
|||
|
||||
After generating code, verify against the design:
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
describe id=<root> → re-check structure matches
|
||||
get_jsx id=<root> → compare JSX structure with generated component tree
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- All text content from the design appears in the code
|
||||
- All colors reference tokens or use correct hex/opacity values
|
||||
- Spacing values match the design
|
||||
|
|
@ -160,18 +166,18 @@ List any deviations with rationale.
|
|||
|
||||
The user specifies the target stack. Adapt code generation accordingly:
|
||||
|
||||
**React + Tailwind** — functional components, TypeScript, utility classes, \`className\`
|
||||
**React + CSS Modules** — functional components, TypeScript, \`.module.css\` files, \`styles.className\`
|
||||
**Vue 3 + Tailwind** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, Tailwind utility classes
|
||||
**Vue 3 + CSS** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, scoped \`<style>\`
|
||||
**Svelte + Tailwind** — \`<script lang="ts">\`, \`$props()\`, Tailwind utility classes
|
||||
**React + Tailwind** — functional components, TypeScript, utility classes, `className`
|
||||
**React + CSS Modules** — functional components, TypeScript, `.module.css` files, `styles.className`
|
||||
**Vue 3 + Tailwind** — `<script setup lang="ts">`, `defineProps`, `<template>`, Tailwind utility classes
|
||||
**Vue 3 + CSS** — `<script setup lang="ts">`, `defineProps`, `<template>`, scoped `<style>`
|
||||
**Svelte + Tailwind** — `<script lang="ts">`, `$props()`, Tailwind utility classes
|
||||
**HTML + CSS** — semantic HTML, BEM or utility classes, CSS custom properties
|
||||
|
||||
If the user hasn't specified a stack, ask before generating code.
|
||||
|
||||
## Component file structure
|
||||
|
||||
\`\`\`
|
||||
```
|
||||
components/
|
||||
Button.tsx (or .vue, .svelte)
|
||||
Card.tsx
|
||||
|
|
@ -183,53 +189,58 @@ pages/
|
|||
assets/
|
||||
icon-arrow.svg
|
||||
icon-check.svg
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
**Auto-layout → Flexbox**
|
||||
- \`layoutMode: HORIZONTAL\` → \`flex-direction: row\`
|
||||
- \`layoutMode: VERTICAL\` → \`flex-direction: column\`
|
||||
- \`itemSpacing\` → \`gap\`
|
||||
- \`paddingTop/Right/Bottom/Left\` → \`padding\`
|
||||
- \`primaryAxisAlign: CENTER\` → \`justify-content: center\`
|
||||
- \`counterAxisAlign: CENTER\` → \`align-items: center\`
|
||||
- \`layoutWrap: WRAP\` → \`flex-wrap: wrap\`
|
||||
- \`primaryAxisSizing: HUG\` → no explicit size on primary axis (content-sized)
|
||||
- \`primaryAxisSizing: FILL\` → \`flex: 1\` or \`width: 100%\` depending on context
|
||||
- \`counterAxisSizing: FILL\` → \`align-self: stretch\` or explicit \`width/height: 100%\`
|
||||
|
||||
- `layoutMode: HORIZONTAL` → `flex-direction: row`
|
||||
- `layoutMode: VERTICAL` → `flex-direction: column`
|
||||
- `itemSpacing` → `gap`
|
||||
- `paddingTop/Right/Bottom/Left` → `padding`
|
||||
- `primaryAxisAlign: CENTER` → `justify-content: center`
|
||||
- `counterAxisAlign: CENTER` → `align-items: center`
|
||||
- `layoutWrap: WRAP` → `flex-wrap: wrap`
|
||||
- `primaryAxisSizing: HUG` → no explicit size on primary axis (content-sized)
|
||||
- `primaryAxisSizing: FILL` → `flex: 1` or `width: 100%` depending on context
|
||||
- `counterAxisSizing: FILL` → `align-self: stretch` or explicit `width/height: 100%`
|
||||
|
||||
**Grid layout**
|
||||
- \`layoutMode: GRID\` → \`display: grid\`
|
||||
- \`gridTemplateColumns\` → \`grid-template-columns\`
|
||||
- \`gridTemplateRows\` → \`grid-template-rows\`
|
||||
- \`gridColumnGap/gridRowGap\` → \`column-gap/row-gap\`
|
||||
|
||||
- `layoutMode: GRID` → `display: grid`
|
||||
- `gridTemplateColumns` → `grid-template-columns`
|
||||
- `gridTemplateRows` → `grid-template-rows`
|
||||
- `gridColumnGap/gridRowGap` → `column-gap/row-gap`
|
||||
|
||||
**Sizing**
|
||||
- \`layoutGrow > 0\` → \`flex-grow: 1\`
|
||||
- \`layoutAlignSelf: STRETCH\` → cross-axis fill
|
||||
- Fixed width/height only when sizing mode is \`FIXED\`
|
||||
|
||||
- `layoutGrow > 0` → `flex-grow: 1`
|
||||
- `layoutAlignSelf: STRETCH` → cross-axis fill
|
||||
- Fixed width/height only when sizing mode is `FIXED`
|
||||
|
||||
**Corner radius**
|
||||
- \`independentCorners: true\` → per-corner border-radius
|
||||
- \`cornerRadius\` → uniform border-radius
|
||||
|
||||
- `independentCorners: true` → per-corner border-radius
|
||||
- `cornerRadius` → uniform border-radius
|
||||
|
||||
**Effects**
|
||||
- \`DROP_SHADOW\` → \`box-shadow\`
|
||||
- \`INNER_SHADOW\` → \`box-shadow: inset ...\`
|
||||
- \`LAYER_BLUR\` → \`filter: blur(...)\`
|
||||
- \`BACKGROUND_BLUR\` → \`backdrop-filter: blur(...)\`
|
||||
|
||||
- `DROP_SHADOW` → `box-shadow`
|
||||
- `INNER_SHADOW` → `box-shadow: inset ...`
|
||||
- `LAYER_BLUR` → `filter: blur(...)`
|
||||
- `BACKGROUND_BLUR` → `backdrop-filter: blur(...)`
|
||||
|
||||
**Text**
|
||||
- \`fontFamily\` → \`font-family\`
|
||||
- \`fontSize\` → \`font-size\`
|
||||
- \`fontWeight\` → \`font-weight\`
|
||||
- \`lineHeight\` → \`line-height\` (null = normal/auto)
|
||||
- \`letterSpacing\` → \`letter-spacing\`
|
||||
- \`textAlignHorizontal\` → \`text-align\`
|
||||
- \`textAutoResize: WIDTH_AND_HEIGHT\` → no explicit dimensions
|
||||
- \`textAutoResize: HEIGHT\` → fixed width, auto height
|
||||
- \`textAutoResize: NONE\` → fixed width and height
|
||||
- \`textDecoration\` → \`text-decoration\`
|
||||
- \`textCase\` → \`text-transform\`
|
||||
`
|
||||
|
||||
- `fontFamily` → `font-family`
|
||||
- `fontSize` → `font-size`
|
||||
- `fontWeight` → `font-weight`
|
||||
- `lineHeight` → `line-height` (null = normal/auto)
|
||||
- `letterSpacing` → `letter-spacing`
|
||||
- `textAlignHorizontal` → `text-align`
|
||||
- `textAutoResize: WIDTH_AND_HEIGHT` → no explicit dimensions
|
||||
- `textAutoResize: HEIGHT` → fixed width, auto height
|
||||
- `textAutoResize: NONE` → fixed width and height
|
||||
- `textDecoration` → `text-decoration`
|
||||
- `textCase` → `text-transform`
|
||||
175
packages/core/src/tools/prompts/jsx-reference.md
Normal file
175
packages/core/src/tools/prompts/jsx-reference.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# OpenPencil JSX Reference
|
||||
|
||||
## Elements
|
||||
|
||||
| Tag | Description |
|
||||
| --------- | -------------------------------------- |
|
||||
| Frame | Container / auto-layout frame |
|
||||
| Rectangle | Rectangle shape |
|
||||
| Ellipse | Circle / ellipse shape |
|
||||
| Text | Text node (children = text content) |
|
||||
| Line | Line shape |
|
||||
| Star | Star shape |
|
||||
| Polygon | Polygon shape (default 3 sides) |
|
||||
| Vector | Vector path |
|
||||
| Group | Group container |
|
||||
| Section | Section (like Frame, for organization) |
|
||||
| Component | Component definition |
|
||||
| Icon | Iconify icon (requires name prop) |
|
||||
|
||||
Aliases: View = Frame, Rect = Rectangle
|
||||
|
||||
## Layout Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------- | ----------------------------------------- | ------------------------------ |
|
||||
| flex | "row" \| "col" | Enable auto-layout direction |
|
||||
| gap | number | Spacing between children |
|
||||
| wrap | boolean | Enable flex wrap |
|
||||
| rowGap | number | Cross-axis gap when wrap is on |
|
||||
| justify | "start" \| "end" \| "center" \| "between" | Main axis alignment |
|
||||
| items | "start" \| "end" \| "center" \| "stretch" | Cross axis alignment |
|
||||
| p | number | Padding (all sides) |
|
||||
| px | number | Horizontal padding |
|
||||
| py | number | Vertical padding |
|
||||
| pt, pr, pb, pl | number | Individual side padding |
|
||||
| grow | number | Flex grow factor |
|
||||
|
||||
## Sizing Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------- | ------------------------- | --------------------------------------- |
|
||||
| w | number \| "fill" \| "hug" | Width |
|
||||
| h | number \| "fill" \| "hug" | Height |
|
||||
| x | number | X position |
|
||||
| y | number | Y position |
|
||||
| position | "absolute" | Absolute positioning inside auto-layout |
|
||||
| top | number | Top offset (sets absolute positioning) |
|
||||
| left | number | Left offset (sets absolute positioning) |
|
||||
| minW | number | Minimum width |
|
||||
| maxW | number | Maximum width |
|
||||
|
||||
## Appearance Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------------- | -------- | -------------------------------------- |
|
||||
| bg | string | Background color (hex, e.g. "#FF0000") |
|
||||
| stroke | string | Stroke color |
|
||||
| strokeWidth | number | Stroke weight (default 1) |
|
||||
| rounded | number | Corner radius (all corners) |
|
||||
| roundedTL | number | Top-left corner radius |
|
||||
| roundedTR | number | Top-right corner radius |
|
||||
| roundedBL | number | Bottom-left corner radius |
|
||||
| roundedBR | number | Bottom-right corner radius |
|
||||
| cornerSmoothing | number | iOS-style corner smoothing |
|
||||
| opacity | number | 0–1 opacity |
|
||||
| rotate | number | Rotation in degrees |
|
||||
| blendMode | string | Blend mode (e.g. "multiply") |
|
||||
| overflow | "hidden" | Clip content |
|
||||
|
||||
## Text Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| -------------- | -------------------------------------------- | -------------------------------------- |
|
||||
| size | number | Font size (default 14) |
|
||||
| font | string | Font family |
|
||||
| weight | number \| "bold" \| "medium" | Font weight |
|
||||
| color | string | Text color (hex) |
|
||||
| textAlign | "left" \| "center" \| "right" \| "justified" | Text alignment |
|
||||
| lineHeight | number | Line height in px |
|
||||
| letterSpacing | number | Letter spacing in px |
|
||||
| textDecoration | "underline" \| "strikethrough" | Text decoration |
|
||||
| textCase | "upper" \| "lower" \| "title" | Text transform |
|
||||
| maxLines | number | Max visible lines (enables truncation) |
|
||||
| truncate | boolean | Enable text truncation |
|
||||
|
||||
## Shape Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ----------- | ------ | ----------------------------------------------- |
|
||||
| points | number | Point count (Star default 5, Polygon default 3) |
|
||||
| innerRadius | number | Star inner radius ratio |
|
||||
|
||||
## Effect Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ------ | ------ | ----------------------------------------- |
|
||||
| shadow | string | Drop shadow: "offsetX offsetY blur color" |
|
||||
| blur | number | Layer blur radius |
|
||||
|
||||
## Grid Layout
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --------- | ---------------- | ----------------------------------------------- |
|
||||
| grid | boolean | Enable CSS Grid layout |
|
||||
| columns | string \| number | Grid template columns (e.g. "1fr 1fr 1fr" or 3) |
|
||||
| rows | string \| number | Grid template rows |
|
||||
| columnGap | number | Column gap |
|
||||
| rowGap | number | Row gap |
|
||||
| colStart | number | Grid column start (child) |
|
||||
| rowStart | number | Grid row start (child) |
|
||||
| colSpan | number | Grid column span (child) |
|
||||
| rowSpan | number | Grid row span (child) |
|
||||
|
||||
## Icon Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
| ----- | ------ | --------------------------------------- |
|
||||
| name | string | Iconify icon name (e.g. "lucide:heart") |
|
||||
| size | number | Icon size (default 24) |
|
||||
| color | string | Icon color (hex) |
|
||||
| label | string | Display name for the icon node |
|
||||
|
||||
## Examples
|
||||
|
||||
```jsx
|
||||
{
|
||||
/* Card with title and description */
|
||||
}
|
||||
;<Frame name="Card" w={320} flex="col" gap={16} p={24} bg="#FFFFFF" rounded={16}>
|
||||
<Text size={18} weight="bold" color="#111">
|
||||
Card Title
|
||||
</Text>
|
||||
<Text size={14} color="#6B7280">
|
||||
Description text here
|
||||
</Text>
|
||||
</Frame>
|
||||
|
||||
{
|
||||
/* Badge in corner of a card */
|
||||
}
|
||||
;<Frame name="Card" w={320} h={200} flex="col" p={24} bg="#FFFFFF" rounded={16}>
|
||||
<Frame position="absolute" top={8} left={280} w={24} h={24} bg="#EF4444" rounded={12}>
|
||||
<Text size={10} weight="bold" color="#FFF">
|
||||
3
|
||||
</Text>
|
||||
</Frame>
|
||||
<Text size={18} weight="bold" color="#111">
|
||||
Card Title
|
||||
</Text>
|
||||
</Frame>
|
||||
|
||||
{
|
||||
/* Horizontal button row */
|
||||
}
|
||||
;<Frame flex="row" gap={8} items="center">
|
||||
<Rectangle w={40} h={40} bg="#3B82F6" rounded={8} />
|
||||
<Text size={14} weight="medium" color="#000">
|
||||
Click me
|
||||
</Text>
|
||||
</Frame>
|
||||
|
||||
{
|
||||
/* Grid layout */
|
||||
}
|
||||
;<Frame grid columns="1fr 1fr 1fr" gap={16} p={16} w={400}>
|
||||
<Rectangle w={100} h={100} bg="#EF4444" rounded={8} />
|
||||
<Rectangle w={100} h={100} bg="#22C55E" rounded={8} />
|
||||
<Rectangle w={100} h={100} bg="#3B82F6" rounded={8} />
|
||||
</Frame>
|
||||
|
||||
{
|
||||
/* Star shape */
|
||||
}
|
||||
;<Star w={48} h={48} points={5} innerRadius={0.38} bg="#EAB308" />
|
||||
```
|
||||
|
|
@ -268,8 +268,14 @@ export const nodeReplaceWith = defineTool({
|
|||
const y = node.y
|
||||
node.remove()
|
||||
const { renderJSX } = await import('../design-jsx/render.js')
|
||||
const result = await renderJSX(figma.graph, args.jsx, { parentId, x, y })
|
||||
return { id: result.id, name: result.name, type: result.type }
|
||||
const results = await renderJSX(figma.graph, args.jsx, { parentId, x, y })
|
||||
const result = results[0]
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
type: result.type,
|
||||
children: results.slice(1).map((r) => ({ id: r.id, name: r.name, type: r.type }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import { defineConfig } from 'tsdown'
|
||||
import type { Plugin } from 'rolldown'
|
||||
|
||||
function rawMd(): Plugin {
|
||||
return {
|
||||
name: 'raw-md',
|
||||
transform(code, id) {
|
||||
if (id.endsWith('.md')) {
|
||||
return { code: `export default ${JSON.stringify(code)}`, map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||
plugins: [rawMd()],
|
||||
unbundle: true,
|
||||
platform: 'neutral',
|
||||
format: ['esm'],
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewpor
|
|||
import { useClipboard } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { selectionToJSX } from '@open-pencil/core'
|
||||
import { selectionToJSX, JSX_REFERENCE } from '@open-pencil/core'
|
||||
import { useI18n, useSceneComputed } from '@open-pencil/vue'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
|
|
@ -34,9 +34,15 @@ const highlightedLines = computed(() => {
|
|||
return jsxCode.value.split('\n').map((line) => Prism.highlight(line, grammar, 'jsx'))
|
||||
})
|
||||
|
||||
const { copy: copyRef, copied: copiedRef } = useClipboard({ copiedDuring: 2000 })
|
||||
|
||||
function copyCode() {
|
||||
copy(jsxCode.value)
|
||||
}
|
||||
|
||||
function copyReference() {
|
||||
copyRef(JSX_REFERENCE)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -63,15 +69,26 @@ function copyCode() {
|
|||
{{ jsxFormat === 'openpencil' ? 'OpenPencil' : 'Tailwind' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
data-test-id="code-panel-copy"
|
||||
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 ? dialogs.copied : dialogs.copy }}
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
data-test-id="code-panel-copy-ref"
|
||||
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover hover:text-surface"
|
||||
title="Copy JSX prop reference to clipboard"
|
||||
@click="copyReference"
|
||||
>
|
||||
<icon-lucide-check v-if="copiedRef" class="size-3 text-green-400" />
|
||||
<icon-lucide-book-open v-else class="size-3" />
|
||||
</button>
|
||||
<button
|
||||
data-test-id="code-panel-copy"
|
||||
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 ? dialogs.copied : dialogs.copy }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollAreaRoot class="min-h-0 flex-1">
|
||||
|
|
|
|||
5
src/global.d.ts
vendored
5
src/global.d.ts
vendored
|
|
@ -42,3 +42,8 @@ interface Window {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
__OPEN_PENCIL_STORE__?: any
|
||||
}
|
||||
|
||||
declare module '*.md' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@ describe('renderJSX (string → scene graph)', () => {
|
|||
<Text name="Hello" size={16} color="#000">World</Text>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
|
||||
expect(result.name).toBe('Test')
|
||||
const node = g.nodes.get(result.id)!
|
||||
|
|
@ -356,7 +356,7 @@ describe('renderJSX (string → scene graph)', () => {
|
|||
<Text name="Description" size={14} color="#6B7280">Lorem ipsum</Text>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
const card = g.nodes.get(result.id)!
|
||||
|
||||
expect(card.layoutMode).toBe('VERTICAL')
|
||||
|
|
@ -365,7 +365,7 @@ describe('renderJSX (string → scene graph)', () => {
|
|||
|
||||
it('renders with position', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
|
||||
const [result] = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
|
||||
const node = g.nodes.get(result.id)!
|
||||
|
||||
expect(node.x).toBe(100)
|
||||
|
|
@ -530,7 +530,7 @@ describe('grid layout rendering', () => {
|
|||
<Rectangle name="B" w={50} h={50} />
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
const frame = g.nodes.get(result.id)!
|
||||
|
||||
expect(frame.layoutMode).toBe('GRID')
|
||||
|
|
@ -605,7 +605,7 @@ describe('grid layout rendering', () => {
|
|||
</Frame>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
computeAllLayouts(g)
|
||||
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
|
||||
expect(grid.width).toBe(360)
|
||||
|
|
@ -623,7 +623,7 @@ describe('grid layout rendering', () => {
|
|||
</Frame>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
computeAllLayouts(g)
|
||||
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
|
||||
expect(grid.width).toBe(350)
|
||||
|
|
@ -642,7 +642,7 @@ describe('grid layout rendering', () => {
|
|||
</Frame>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
computeAllLayouts(g)
|
||||
const content = g.getChildren(result.id).find(c => c.name === 'Content')!
|
||||
const grid = g.getChildren(content.id).find(c => c.name === 'G')!
|
||||
|
|
@ -661,7 +661,7 @@ describe('grid layout rendering', () => {
|
|||
</Frame>
|
||||
</Frame>
|
||||
`
|
||||
const result = await renderJSX(g, jsx)
|
||||
const [result] = await renderJSX(g, jsx)
|
||||
computeAllLayouts(g)
|
||||
const grid = g.getChildren(result.id).find(c => c.name === 'G')!
|
||||
expect(grid.height).toBe(460)
|
||||
|
|
@ -671,7 +671,7 @@ describe('grid layout rendering', () => {
|
|||
describe('text props round-trip', () => {
|
||||
it('lineHeight renders and exports', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.lineHeight).toBe(24)
|
||||
const jsx = sceneNodeToJSX(n.id, g)
|
||||
|
|
@ -680,7 +680,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('letterSpacing renders and exports', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.letterSpacing).toBe(2)
|
||||
const jsx = sceneNodeToJSX(n.id, g)
|
||||
|
|
@ -689,7 +689,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('textDecoration renders and exports', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.textDecoration).toBe('UNDERLINE')
|
||||
const jsx = sceneNodeToJSX(n.id, g)
|
||||
|
|
@ -698,7 +698,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('textCase renders and exports', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.textCase).toBe('UPPER')
|
||||
const jsx = sceneNodeToJSX(n.id, g)
|
||||
|
|
@ -707,7 +707,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('maxLines renders with truncation', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.maxLines).toBe(2)
|
||||
expect(n.textTruncation).toBe('ENDING')
|
||||
|
|
@ -717,7 +717,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('truncate without maxLines', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.textTruncation).toBe('ENDING')
|
||||
const jsx = sceneNodeToJSX(n.id, g)
|
||||
|
|
@ -726,7 +726,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('defaults omit text props', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, '<Text color="#000">Plain</Text>')
|
||||
const [result] = await renderJSX(g, '<Text color="#000">Plain</Text>')
|
||||
const n = g.getNode(result.id)!
|
||||
expect(n.lineHeight).toBeNull()
|
||||
expect(n.letterSpacing).toBe(0)
|
||||
|
|
@ -744,7 +744,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('text w="fill" in flex="col" exports as w="fill" not w={computed}', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, `
|
||||
const [result] = await renderJSX(g, `
|
||||
<Frame name="Card" flex="col" w={300} p={20}>
|
||||
<Text name="Title" size={22} weight="bold" color="#111" w="fill">Hello World</Text>
|
||||
</Frame>
|
||||
|
|
@ -760,7 +760,7 @@ describe('text props round-trip', () => {
|
|||
|
||||
it('text grow={1} in flex="row" exports as grow not w={computed}', async () => {
|
||||
const g = createGraph()
|
||||
const result = await renderJSX(g, `
|
||||
const [result] = await renderJSX(g, `
|
||||
<Frame name="Row" flex="row" w={300}>
|
||||
<Text name="Label" color="#999" w={60}>Label</Text>
|
||||
<Text name="Value" color="#111" w="fill">Some value text</Text>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,14 @@ export default defineConfig(async ({ command }) => ({
|
|||
)
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'raw-md',
|
||||
transform(code: string, id: string) {
|
||||
if (id.endsWith('.md')) {
|
||||
return { code: `export default ${JSON.stringify(code)}`, map: null }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'copy-canvaskit-wasm',
|
||||
buildStart() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue