diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 801a121e0..f783c6bd9 100644 --- a/packages/core/src/design-jsx/props-overrides.ts +++ b/packages/core/src/design-jsx/props-overrides.ts @@ -29,6 +29,19 @@ const TEXT_ALIGN_MAP: Record = { justified: 'JUSTIFIED' } +const TEXT_VERTICAL_ALIGN_MAP: Record = { + top: 'TOP', + center: 'CENTER', + bottom: 'BOTTOM' +} + +const TEXT_ALIGN_ALIAS_MAP: Record = { + ...TEXT_ALIGN_MAP, + left_align: 'LEFT', + center_align: 'CENTER', + right_align: 'RIGHT' +} + const TEXT_AUTO_RESIZE_MAP: Record = { none: 'NONE', width: 'WIDTH_AND_HEIGHT', @@ -141,13 +154,18 @@ function applyVisualOverrides(props: Record, o: Partial, o: Partial): void { + const rotation = props.rotate ?? props.rotation + if (rotation !== undefined) o.rotation = rotation as number +} + function applyPaddingOverrides(props: Record, o: Partial): void { const p = props.p ?? props.padding if (typeof p === 'number') { @@ -173,7 +191,14 @@ function applyPaddingOverrides(props: Record, o: Partial): boolean { return AUTO_LAYOUT_TRIGGER_KEYS.some((k) => props[k] !== undefined) @@ -272,6 +297,20 @@ function applyAutoLayoutSizing( if (counterDim === 'hug') o.counterAxisSizing = 'HUG' } +function applyLayoutAlignmentOverrides( + props: Record, + o: Partial +): void { + const justify = props.justify ?? props.justifyContent + if (justify) { + o.primaryAxisAlign = ALIGN_MAP[justify as string] ?? 'MIN' + } + const items = props.items ?? props.align ?? props.alignItems + if (items) { + o.counterAxisAlign = COUNTER_ALIGN_MAP[items as string] ?? 'MIN' + } +} + function shouldEnableAutoLayout(props: Record, isText: boolean): boolean { if (props.flex !== undefined) return true if (!isText && hasAutoLayoutTriggerProps(props)) return true @@ -311,12 +350,7 @@ function applyLayoutOverrides( if (props.rowGap !== undefined) o.counterAxisSpacing = props.rowGap as number } - if (props.justify) { - o.primaryAxisAlign = ALIGN_MAP[props.justify as string] ?? 'MIN' - } - if (props.items) { - o.counterAxisAlign = COUNTER_ALIGN_MAP[props.items as string] ?? 'MIN' - } + applyLayoutAlignmentOverrides(props, o) applyPaddingOverrides(props, o) @@ -358,8 +392,21 @@ function applyTextStyleOverrides(props: Record, o: Partial, + o: Partial +): void { + const textAlign = props.textAlign ?? props.textAlignHorizontal ?? props.textHorizontalAlignment + if (typeof textAlign === 'string') { + o.textAlignHorizontal = TEXT_ALIGN_ALIAS_MAP[textAlign.toLowerCase()] ?? 'LEFT' + } + + const textAlignVertical = props.textAlignVertical ?? props.textVerticalAlignment + if (typeof textAlignVertical === 'string') { + o.textAlignVertical = TEXT_VERTICAL_ALIGN_MAP[textAlignVertical.toLowerCase()] ?? 'TOP' } } diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index fbec15d63..36206ff71 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -4,14 +4,128 @@ import type { SceneGraph } from '#core/scene-graph' import * as React from './mini-react' import { renderTree, type RenderResult } from './renderer' -import { resolveToTree } from './tree' +import { isTreeNode, resolveToTree, type TreeNode } from './tree' /** * Build a component function from a JSX string using sucrase. * Works in both Node/Bun and the browser (no native bindings). */ +const SUPPORTED_PROPS = new Set([ + 'name', + 'key', + 'flex', + 'flow', + 'dir', + 'gap', + 'wrap', + 'rowGap', + 'columnGap', + 'justify', + 'justifyContent', + 'items', + 'align', + 'alignItems', + 'grow', + 'w', + 'h', + 'width', + 'height', + 'minW', + 'maxW', + 'minH', + 'maxH', + 'x', + 'y', + 'top', + 'left', + 'position', + 'p', + 'padding', + 'px', + 'py', + 'pt', + 'pr', + 'pb', + 'pl', + 'bg', + 'fill', + 'stroke', + 'strokeWidth', + 'strokeAlign', + 'strokeDash', + 'rounded', + 'roundedTL', + 'roundedTR', + 'roundedBL', + 'roundedBR', + 'cornerRadius', + 'cornerSmoothing', + 'opacity', + 'blendMode', + 'rotate', + 'rotation', + 'overflow', + 'shadow', + 'blur', + 'size', + 'fontSize', + 'font', + 'fontFamily', + 'weight', + 'fontWeight', + 'color', + 'text', + 'characters', + 'textAlign', + 'textAlignHorizontal', + 'textHorizontalAlignment', + 'textAlignVertical', + 'textVerticalAlignment', + 'textAutoResize', + 'lineHeight', + 'letterSpacing', + 'textDecoration', + 'textCase', + 'maxLines', + 'truncate', + 'grid', + 'columns', + 'rows', + 'colStart', + 'rowStart', + 'col', + 'row', + 'colSpan', + 'rowSpan', + 'points', + 'pointCount', + 'innerRadius', + 'label' +]) + +function stripHtmlComments(jsxString: string): string { + return jsxString.replace(//g, '') +} + +function unsupportedPropWarnings(tree: TreeNode): string[] { + const warnings: string[] = [] + collectUnsupportedPropWarnings(tree, warnings) + return warnings +} + +function collectUnsupportedPropWarnings(tree: TreeNode, warnings: string[]): void { + for (const key of Object.keys(tree.props)) { + if (!SUPPORTED_PROPS.has(key)) { + warnings.push(`Unsupported prop "${key}" on <${tree.type}> is ignored.`) + } + } + for (const child of tree.children) { + if (isTreeNode(child)) collectUnsupportedPropWarnings(child, warnings) + } +} + export function buildComponent(jsxString: string): React.ComponentType { - const trimmed = jsxString.trim() + const trimmed = stripHtmlComments(jsxString).trim() const aliases = ` const __h = React.createElement @@ -63,6 +177,8 @@ export async function renderJSX( throw new Error('JSX must return a Figma element (Frame, Text, etc)') } + const warnings = unsupportedPropWarnings(tree) + if (tree.type === '' && tree.children.length > 0) { const results: RenderResult[] = [] for (const child of tree.children) { @@ -72,10 +188,13 @@ export async function renderJSX( if (results.length === 0) { throw new Error('JSX must return a Figma element (Frame, Text, etc)') } + if (warnings.length > 0) results[0].warnings = warnings return results } - return [await renderTree(graph, tree, options)] + const result = await renderTree(graph, tree, options) + if (warnings.length > 0) result.warnings = warnings + return [result] } export { renderTree as renderTreeNode } diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index 442a6b574..6db214d31 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -35,6 +35,7 @@ export interface RenderResult { name: string type: NodeType childIds: string[] + warnings?: string[] } export async function renderTree( @@ -102,8 +103,10 @@ async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): const overrides = propsToOverrides(tree.props, isText, parentLayout) if (isText) { - const textContent = tree.children.filter((c): c is string => typeof c === 'string').join('') - if (textContent) overrides.text = textContent + const childText = tree.children.filter((c): c is string => typeof c === 'string').join('') + const propText = tree.props.text ?? tree.props.characters + if (childText) overrides.text = childText + else if (typeof propText === 'string') overrides.text = propText } const node = graph.createNode(nodeType, parentId, overrides) diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 3c299ddeb..db0a5d1de 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -89,7 +89,10 @@ export type StyleProps = { wrap?: boolean rowGap?: number justify?: 'start' | 'end' | 'center' | 'between' + justifyContent?: 'start' | 'end' | 'center' | 'between' items?: 'start' | 'end' | 'center' | 'stretch' + align?: 'start' | 'end' | 'center' | 'stretch' + alignItems?: 'start' | 'end' | 'center' | 'stretch' grow?: number w?: number | 'fill' | 'hug' @@ -125,6 +128,7 @@ export type StyleProps = { opacity?: number blendMode?: string rotate?: number + rotation?: number overflow?: 'hidden' | 'visible' shadow?: string blur?: number @@ -136,7 +140,13 @@ export type StyleProps = { weight?: number | 'bold' | 'medium' | 'normal' fontWeight?: number | 'bold' | 'medium' | 'normal' color?: string + text?: string + characters?: string textAlign?: 'left' | 'center' | 'right' | 'justified' + textAlignHorizontal?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' + textHorizontalAlignment?: 'LEFT' | 'CENTER' | 'RIGHT' | 'JUSTIFIED' + textAlignVertical?: 'TOP' | 'CENTER' | 'BOTTOM' + textVerticalAlignment?: 'TOP' | 'CENTER' | 'BOTTOM' textAutoResize?: 'none' | 'width' | 'height' } diff --git a/packages/core/src/figma-api/serialization.ts b/packages/core/src/figma-api/serialization.ts index 75f59ff21..43df94e68 100644 --- a/packages/core/src/figma-api/serialization.ts +++ b/packages/core/src/figma-api/serialization.ts @@ -28,7 +28,21 @@ export function nodeProxyToJSON( if (n.cornerRadius > 0) obj.cornerRadius = n.cornerRadius if (!n.visible) obj.visible = false if (n.text) obj.characters = n.text - if (n.type === 'TEXT') obj.textDirection = n.textDirection + if (n.type === 'TEXT') { + obj.fontFamily = n.fontFamily + obj.fontSize = n.fontSize + obj.fontWeight = n.fontWeight + obj.italic = n.italic + obj.textAlignHorizontal = n.textAlignHorizontal + obj.textAlignVertical = n.textAlignVertical + obj.textAutoResize = n.textAutoResize + obj.textDirection = n.textDirection + if (n.lineHeight != null) obj.lineHeight = n.lineHeight + if (n.letterSpacing !== 0) obj.letterSpacing = n.letterSpacing + if (n.textCase !== 'ORIGINAL') obj.textCase = n.textCase + if (n.textDecoration !== 'NONE') obj.textDecoration = n.textDecoration + if (n.maxLines != null) obj.maxLines = n.maxLines + } if (n.layoutMode !== 'NONE') { obj.layoutMode = n.layoutMode obj.layoutDirection = n.layoutDirection diff --git a/packages/core/src/tools/create/render.ts b/packages/core/src/tools/create/render.ts index a3e007e1c..66aca1f3d 100644 --- a/packages/core/src/tools/create/render.ts +++ b/packages/core/src/tools/create/render.ts @@ -55,6 +55,7 @@ export const render = defineTool({ name: result.name, type: result.type, children: result.childIds, + ...(result.warnings ? { warnings: result.warnings } : {}), ...(results.length > 1 ? { siblings: results diff --git a/packages/core/src/tools/structure/replace.ts b/packages/core/src/tools/structure/replace.ts index 71721dba7..d649f7dd7 100644 --- a/packages/core/src/tools/structure/replace.ts +++ b/packages/core/src/tools/structure/replace.ts @@ -22,6 +22,7 @@ export const nodeReplaceWith = defineTool({ id: result.id, name: result.name, type: result.type, + ...(result.warnings ? { warnings: result.warnings } : {}), children: results .slice(1) .map((child) => ({ id: child.id, name: child.name, type: child.type })) diff --git a/src/app/ai/tools/index.ts b/src/app/ai/tools/index.ts index 0de2258e1..1cc049902 100644 --- a/src/app/ai/tools/index.ts +++ b/src/app/ai/tools/index.ts @@ -4,12 +4,12 @@ import * as v from 'valibot' import { computeAllLayouts } from '@open-pencil/core/layout' import type { SceneNode } from '@open-pencil/core/scene-graph' -import { fontManager } from '@open-pencil/core/text' import { CORE_TOOLS, toolsToAI } from '@open-pencil/core/tools' import type { StepBudget, ToolLogEntry } from '@open-pencil/core/tools' import { makeFigmaFromStore } from '@/app/automation/bridge/figma-factory' import { getActiveEditorStore } from '@/app/editor/active-store' +import { ensureGraphFonts } from '@/app/editor/fonts' import type { EditorStore } from '@/app/editor/active-store' export const MAX_AGENT_STEPS = 50 @@ -99,20 +99,7 @@ export function createAITools(store: EditorStore) { if (def.mutates) { const pageId = store.state.currentPageId const pageNode = store.graph.getNode(pageId) - if (pageNode) { - const fontKeys = fontManager.collectFontKeys(store.graph, pageNode.childIds) - const missing = fontKeys.filter(([family]) => !fontManager.isLoaded(family)) - if (missing.length > 0) { - const results = await Promise.all( - missing.map(([family, style]) => fontManager.loadFont(family, style)) - ) - if (results.some((r) => r !== null)) { - for (const [, node] of store.graph.nodes) { - if (node.type === 'TEXT' && node.textPicture) node.textPicture = null - } - } - } - } + if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds) computeAllLayouts(store.graph, pageId) store.requestRender() if (beforeSnapshot) { diff --git a/src/app/automation/bridge/tool-handlers.ts b/src/app/automation/bridge/tool-handlers.ts index 8d0b38f89..aa4efd060 100644 --- a/src/app/automation/bridge/tool-handlers.ts +++ b/src/app/automation/bridge/tool-handlers.ts @@ -4,6 +4,7 @@ import { computeAllLayouts } from '@open-pencil/core/layout' import { ALL_TOOLS } from '@open-pencil/core/tools' import type { EditorStore } from '@/app/editor/active-store' +import { ensureGraphFonts } from '@/app/editor/fonts' type FigmaFactory = () => FigmaAPI @@ -18,6 +19,7 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { x: toolArgs.x as number | undefined, y: toolArgs.y as number | undefined }) + await ensureGraphFonts(store.graph, [result.id]) computeAllLayouts(store.graph, store.state.currentPageId) store.requestRender() store.flashNodes([result.id]) @@ -46,6 +48,8 @@ export function createAutomationToolHandler(makeFigma: FigmaFactory) { } if (def.mutates) { + const pageNode = store.graph.getNode(store.state.currentPageId) + if (pageNode) await ensureGraphFonts(store.graph, pageNode.childIds) computeAllLayouts(store.graph, store.state.currentPageId) store.requestRender() store.flashNodes(extractNodeIds(result)) diff --git a/src/app/editor/fonts/index.ts b/src/app/editor/fonts/index.ts index 2a16c9da6..d715a2067 100644 --- a/src/app/editor/fonts/index.ts +++ b/src/app/editor/fonts/index.ts @@ -1,5 +1,6 @@ import { useLocalStorage } from '@vueuse/core' +import type { SceneGraph } from '@open-pencil/core/scene-graph' import { fontManager, styleToWeight, @@ -113,6 +114,23 @@ export async function listFonts(): Promise { return [] } +export async function ensureGraphFonts(graph: SceneGraph, nodeIds: string[]): Promise { + const fontKeys = fontManager.collectFontKeys(graph, nodeIds) + const missing = fontKeys.filter(([family, style]) => !fontManager.isStyleLoaded(family, style)) + if (missing.length === 0) return false + + const results = await Promise.all(missing.map(([family, style]) => loadFont(family, style))) + const loaded = results.some((result) => result !== null) + if (loaded) clearTextPictures(graph) + return loaded +} + +function clearTextPictures(graph: SceneGraph): void { + for (const [, node] of graph.nodes) { + if (node.type === 'TEXT') node.textPicture = null + } +} + export async function loadFont(family: string, style = 'Regular'): Promise { configureTauriFontCache() if (isTauri()) { diff --git a/tests/engine/render/jsx/render-tree.test.ts b/tests/engine/render/jsx/render-tree.test.ts index e72174b83..cbf669a2f 100644 --- a/tests/engine/render/jsx/render-tree.test.ts +++ b/tests/engine/render/jsx/render-tree.test.ts @@ -322,4 +322,43 @@ describe('renderJSX (string → scene graph)', () => { expect(node.x).toBe(100) expect(node.y).toBe(200) }) + + it('accepts rotation as an alias for rotate', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX(g, '') + const node = getNodeOrThrow(g, result.id) + + expect(node.rotation).toBe(15) + }) + + it('strips HTML comments before JSX parsing', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX( + g, + 'Ok' + ) + const node = getNodeOrThrow(g, result.id) + + expect(node.name).toBe('Comments') + expect(node.childIds.length).toBe(1) + }) + + it('warns about unsupported props', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX(g, '') + + expect(result.warnings).toEqual(['Unsupported prop "mt" on is ignored.']) + }) + + it('accepts CSS-style layout aliases', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX( + g, + '' + ) + const node = getNodeOrThrow(g, result.id) + + expect(node.primaryAxisAlign).toBe('CENTER') + expect(node.counterAxisAlign).toBe('CENTER') + }) }) diff --git a/tests/engine/render/jsx/text-props.test.ts b/tests/engine/render/jsx/text-props.test.ts index 6ef1a02ba..125eb2481 100644 --- a/tests/engine/render/jsx/text-props.test.ts +++ b/tests/engine/render/jsx/text-props.test.ts @@ -61,6 +61,36 @@ describe('text props round-trip', () => { expect(jsx).toContain('truncate') }) + it('accepts Figma-style text aliases', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX( + g, + '' + ) + const n = getNodeOrThrow(g, result.id) + expect(n.text).toBe('Alias text') + expect(n.fontSize).toBe(24) + expect(n.fontFamily).toBe('Inter') + expect(n.fontWeight).toBe(700) + expect(n.textAlignHorizontal).toBe('CENTER') + expect(n.textAlignVertical).toBe('CENTER') + expect(n.fills[0]?.type).toBe('SOLID') + }) + + it('accepts characters as a text content alias', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX(g, '') + const n = getNodeOrThrow(g, result.id) + expect(n.text).toBe('Characters text') + }) + + it('prefers text children over text prop alias', async () => { + const g = makeSceneGraph() + const [result] = await renderJSX(g, 'Child text') + const n = getNodeOrThrow(g, result.id) + expect(n.text).toBe('Child text') + }) + it('defaults omit text props', async () => { const g = makeSceneGraph() const [result] = await renderJSX(g, 'Plain') diff --git a/tests/engine/tools/create.test.ts b/tests/engine/tools/create.test.ts index 4b6634d0d..06cc4a417 100644 --- a/tests/engine/tools/create.test.ts +++ b/tests/engine/tools/create.test.ts @@ -67,4 +67,30 @@ describe('render', () => { expect(result.type).toBe('FRAME') expect(result.children.length).toBeGreaterThan(0) }) + + test('returns JSX warnings', async () => { + const { figma } = setupToolTest() + const tool = getTool('render') + const result = (await tool.execute(figma, { + jsx: '' + })) as ToolResult + expect(result.warnings).toEqual(['Unsupported prop "mt" on is ignored.']) + }) + + test('get_node exposes text style fields', async () => { + const { figma } = setupToolTest() + const render = getTool('render') + const card = (await render.execute(figma, { + jsx: 'Hello' + })) as ToolResult + const textId = (card.children as string[])[0] + const getNode = getTool('get_node') + const result = getNode.execute(figma, { id: textId, depth: 0 }) as ToolResult + + expect(result.characters).toBe('Hello') + expect(result.fontFamily).toBe('Inter') + expect(result.fontSize).toBe(24) + expect(result.fontWeight).toBe(700) + expect(result.textAlignHorizontal).toBe('CENTER') + }) })