diff --git a/packages/core/src/design-jsx/props-overrides.ts b/packages/core/src/design-jsx/props-overrides.ts index 034fdc3cb..8e18389df 100644 --- a/packages/core/src/design-jsx/props-overrides.ts +++ b/packages/core/src/design-jsx/props-overrides.ts @@ -246,6 +246,14 @@ function applyVisualOverrides(props: Record, o: Partial = { + luminance: 'LUMINANCE', + vector: 'VECTOR' + } + o.maskType = maskTypeMap[props.mask as string] ?? 'ALPHA' + } } function applyTransformOverrides(props: Record, o: Partial): void { diff --git a/packages/core/src/design-jsx/render.ts b/packages/core/src/design-jsx/render.ts index 393e411a9..8427b4a68 100644 --- a/packages/core/src/design-jsx/render.ts +++ b/packages/core/src/design-jsx/render.ts @@ -162,6 +162,7 @@ export function buildComponent(jsxString: string): React.ComponentType { const Group = 'group', Section = 'section', View = 'frame', Rect = 'rectangle' const Component = 'component', ComponentSet = 'component-set', Instance = 'instance' const Icon = 'icon' + const svg = 'svg' const dropShadow = __helpers.dropShadow const innerShadow = __helpers.innerShadow const layerBlur = __helpers.layerBlur diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index 9470feabe..66d76b0e6 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -10,6 +10,8 @@ import { parseColor } from '#core/color' import type { RenderOptions } from '#core/design-jsx/types' import { fetchIcons } from '#core/icons' import { createIconFromPaths } from '#core/icons/render' +import { extractPaths, scalePathInfos } from '#core/icons/svg' +import type { IconData } from '#core/icons/types' import { computeAllLayouts } from '#core/layout' import { randomHex } from '#core/random' @@ -190,6 +192,33 @@ function applyBindings(graph: SceneGraph, nodeId: string, bindings: Record, + overrides: Partial, + parentLayout: SceneNode['layoutMode'], + size: number +): void { + const { w, h } = applySizeOverrides(props, overrides, parentLayout) + if (typeof w !== 'number') overrides.width = size + if (typeof h !== 'number') overrides.height = size +} + +function finishIconRender( + graph: SceneGraph, + icon: IconData, + props: Record, + size: number, + color: Color, + parentId: string +): SceneNode { + const parent = graph.getNode(parentId) + const parentLayout = parent?.layoutMode ?? 'NONE' + const overrides: Partial = {} + if (props.label) overrides.name = props.label as string + applyIconSize(props, overrides, parentLayout, size) + return createIconFromPaths(graph, icon, icon.name, size, color, parentId, overrides) +} + async function renderIconNode( graph: SceneGraph, tree: TreeNode, @@ -208,16 +237,74 @@ async function renderIconNode( if (!icon || icon.paths.length === 0) { throw new Error(`Icon "${iconName}" not found`) } + return finishIconRender(graph, icon, props, size, parsedColor, parentId) +} - const parent = graph.getNode(parentId) - const parentLayout = parent?.layoutMode ?? 'NONE' - const overrides: Partial = {} - if (props.label) overrides.name = props.label as string - const { w, h } = applySizeOverrides(props, overrides, parentLayout) - if (typeof w !== 'number') overrides.width = size - if (typeof h !== 'number') overrides.height = size +/** + * Render an inline element into vector nodes. Reuses the same SVG-path + * pipeline as iconify icons: the body may be passed as string children or a + * `body`/`children` string prop, and is parsed with extractPaths + parseSVGPath. + */ +async function renderSvgNode( + graph: SceneGraph, + tree: TreeNode, + parentId: string +): Promise { + const props = tree.props + const size = (props.size as number | undefined) ?? 24 + const colorHex = (props.color as string | undefined) ?? '#000000' + const parsedColor = parseColor(colorHex) - return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides) + const body = + (typeof props.body === 'string' && props.body) || + tree.children.filter((c): c is string => typeof c === 'string').join('') + + // Children may arrive as parsed //etc. elements (mini-react + // lowercases tags) rather than raw markup. Rebuild path info from either + // source: raw SVG markup, or element children carrying a `d` attribute. + let pathInfos = body.trim() ? extractPaths(body) : [] + if (pathInfos.length === 0) { + pathInfos = tree.children + .filter(isTreeNode) + .map((child) => { + const d = (child.props.d ?? child.props.body) as string | undefined + if (!d) return null + return { + d, + fill: (child.props.fill as string | undefined) ?? 'currentColor', + stroke: (child.props.stroke as string | undefined) ?? null, + strokeWidth: Number(child.props['stroke-width'] ?? child.props.strokeWidth ?? 1), + strokeCap: (child.props['stroke-linecap'] as string | undefined) ?? 'butt', + strokeJoin: (child.props['stroke-linejoin'] as string | undefined) ?? 'miter', + fillRule: (child.props['fill-rule'] as string | undefined) === 'evenodd' ? 'EVENODD' as const : 'NONZERO' as const + } + }) + .filter((p): p is NonNullable => p !== null) + } + if (pathInfos.length === 0) { + throw new Error(' requires SVG markup as children, a body prop, or children') + } + + const vb = parseViewBox(props.viewBox as string | undefined) + const scaleX = vb.w > 0 ? size / vb.w : 1 + const scaleY = vb.h > 0 ? size / vb.h : 1 + + const icon: IconData = { + prefix: 'svg', + name: (props.name as string | undefined) ?? 'custom', + width: size, + height: size, + paths: scalePathInfos(pathInfos, scaleX, scaleY) + } + return finishIconRender(graph, icon, props, size, parsedColor, parentId) +} + +function parseViewBox(viewBox: string | undefined): { w: number; h: number } { + if (!viewBox) return { w: 0, h: 0 } + const parts = viewBox.trim().split(/[\s,]+/).map(Number) + const w = parts[2] ?? 0 + const h = parts[3] ?? 0 + return { w, h } } function parseVariantValues(name: string): Record { @@ -343,11 +430,54 @@ async function renderInstanceNode( const instance = graph.createInstance(component.id, parentId, overrides) ?? graph.createNode('FRAME', parentId) applyBindings(graph, instance.id, bindings) + applyInstanceOverrides(graph, instance, tree.props.overrides) return instance } +/** + * Apply child overrides to a freshly created instance. Keys are + * `childName:prop` (e.g. 'label:text', 'icon:fills'); the child is resolved by + * name among the instance's descendants, and the value is applied to both the + * child node and the instance's overrides record so component sync keeps it. + */ +function applyInstanceOverrides( + graph: SceneGraph, + instance: SceneNode, + overridesProp: unknown +): void { + if (!overridesProp || typeof overridesProp !== 'object') return + if (Array.isArray(overridesProp)) return + const entries = Object.entries(overridesProp) + if (entries.length === 0) return + + const descendants: SceneNode[] = [] + const walk = (id: string) => { + const node = graph.getNode(id) + if (!node) return + descendants.push(node) + for (const cid of node.childIds) walk(cid) + } + walk(instance.id) + + const overrides: Record = { ...instance.overrides } + for (const [key, value] of entries) { + const sep = key.indexOf(':') + if (sep === -1) continue + const childName = key.slice(0, sep) + const prop = key.slice(sep + 1) + const child = descendants.find((n) => n.name === childName) + if (!child || !(prop in child)) continue + graph.updateNode(child.id, { [prop]: value } as Partial) + overrides[`${child.id}:${prop}`] = value + } + if (Object.keys(overrides).length > 0) { + graph.updateNode(instance.id, { overrides }) + } +} + async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise { if (tree.type === 'icon') return renderIconNode(graph, tree, parentId) + if (tree.type === 'svg') return renderSvgNode(graph, tree, parentId) if (tree.type === 'instance') return renderInstanceNode(graph, tree, parentId) const nodeType = TYPE_MAP[tree.type] diff --git a/packages/core/src/design-jsx/tree.ts b/packages/core/src/design-jsx/tree.ts index 94ab174dd..8554c5622 100644 --- a/packages/core/src/design-jsx/tree.ts +++ b/packages/core/src/design-jsx/tree.ts @@ -135,6 +135,7 @@ export type StyleProps = { cornerSmoothing?: number opacity?: number blendMode?: string + mask?: boolean | 'alpha' | 'luminance' | 'vector' rotate?: number rotation?: number overflow?: 'hidden' | 'visible' diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 572ed4db3..afbf8f02f 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -153,19 +153,28 @@ export function buildIconData( name: iconName, width: size, height: size, - paths: pathInfos.map((path) => { - const scaledD = - scaleX === 1 && scaleY === 1 - ? path.d - : svgpath(path.d).scale(scaleX, scaleY).round(2).toString() - return { - vectorNetwork: parseSVGPath(scaledD, path.fillRule), - fill: path.fill, - stroke: path.stroke, - strokeWidth: path.strokeWidth * Math.min(scaleX, scaleY), - strokeCap: path.strokeCap, - strokeJoin: path.strokeJoin - } - }) + paths: scalePathInfos(pathInfos, scaleX, scaleY) } } + +/** Scale extracted SVG path info into IconData paths (shared by buildIconData and design-jsx ). */ +export function scalePathInfos( + pathInfos: IconPathInfo[], + scaleX: number, + scaleY: number +): IconData['paths'] { + return pathInfos.map((path) => { + const scaledD = + scaleX === 1 && scaleY === 1 + ? path.d + : svgpath(path.d).scale(scaleX, scaleY).round(2).toString() + return { + vectorNetwork: parseSVGPath(scaledD, path.fillRule), + fill: path.fill, + stroke: path.stroke, + strokeWidth: path.strokeWidth * Math.min(scaleX, scaleY), + strokeCap: path.strokeCap, + strokeJoin: path.strokeJoin + } + }) +} diff --git a/src/app/demo/document.ts b/src/app/demo/document.ts index 15efa0460..04e2a92aa 100644 --- a/src/app/demo/document.ts +++ b/src/app/demo/document.ts @@ -1,17 +1,20 @@ import { computeAllLayouts } from '@open-pencil/core/layout' +import { renderJSX } from '@open-pencil/core/design-jsx' import { DEMO_COLORS, solid } from '@/app/demo/colors' +import { SHOWCASE_JSX } from '@/app/demo/jsx/showcase' import { createAppPreviewSection } from '@/app/demo/sections/app-preview' import { createComponentsSection } from '@/app/demo/sections/components' import { createDemoVariables } from '@/app/demo/sections/variables' import type { EditorStore } from '@/app/editor/session' -export function createDemoShapes(store: EditorStore) { +export async function createDemoShapes(store: EditorStore) { const { graph } = store const comps = createComponentsSection(store) computeAllLayouts(graph) const app = createAppPreviewSection(store, comps) + await renderJSX(graph, SHOWCASE_JSX) createDemoVariables(store) // Theme the screen through variables so editing one re-themes the demo. diff --git a/src/app/demo/jsx/showcase.ts b/src/app/demo/jsx/showcase.ts new file mode 100644 index 000000000..bc06fb358 --- /dev/null +++ b/src/app/demo/jsx/showcase.ts @@ -0,0 +1,94 @@ +/** + * The showcase page: a music player app authored entirely in JSX. Exercises + * masks, gradients, drop shadows, a glassmorphic player bar, blend-mode + * overlays, corner smoothing, vector icons, outlined buttons, and + * component/instance — all as natural parts of one design. + */ +export const SHOWCASE_JSX = ` +
+ + {/* Hero: gradient cover masked into a smooth-rounded card, with a + blend-mode legibility overlay and masked listener avatars. */} + + + {/* Up-next: album art masked into rounded squares. */} + Up next + + + {/* Player bar: glassmorphism (background blur + translucent fill). */} + +
+` diff --git a/src/views/EditorView.vue b/src/views/EditorView.vue index 8c4312299..97295cf97 100644 --- a/src/views/EditorView.vue +++ b/src/views/EditorView.vue @@ -40,7 +40,7 @@ const { dialogs } = useI18n() const { isMobile } = useViewportKind() if (createdInitialTab && route.meta.demo && !('test' in params)) { - createDemoShapes(firstTab.store) + void createDemoShapes(firstTab.store) } useHead({ title: route.meta.demo ? 'Demo' : undefined }) diff --git a/tests/engine/render/jsx/gaps.test.ts b/tests/engine/render/jsx/gaps.test.ts new file mode 100644 index 000000000..8c13c6e61 --- /dev/null +++ b/tests/engine/render/jsx/gaps.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'bun:test' + +import { renderJSX } from '@open-pencil/core' + +import { makeSceneGraph } from '#tests/helpers/scene' + +describe('jsx gaps', () => { + it('mask prop sets isMask + maskType', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + `` + ) + const ellipse = [...g.nodes.values()].find((n) => n.type === 'ELLIPSE') + expect(ellipse?.isMask).toBe(true) + expect(ellipse?.maskType).toBe('ALPHA') + }) + + it('svg element renders vector paths', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + `` + ) + const vector = [...g.nodes.values()].find((n) => n.type === 'VECTOR') + expect(vector).toBeTruthy() + expect(vector?.vectorNetwork).toBeTruthy() + }) + + it('instance overrides apply child text by name', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + `+0% + ` + ) + const inst = [...g.nodes.values()].find((n) => n.type === 'INSTANCE') + const label = [...g.nodes.values()].find( + (n) => n.type === 'TEXT' && inst && n.parentId === inst.id + ) + expect(label?.text).toBe('+14%') + }) +})