feat(design-jsx): support structured paints
This commit is contained in:
parent
4b65e64c18
commit
cc1167eed5
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
- Add JSX authoring support for components, component sets, and instances.
|
||||
- Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings.
|
||||
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
|
||||
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
|
||||
- Add `unbind_variable` MCP tool for removing variable bindings.
|
||||
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.
|
||||
|
|
|
|||
|
|
@ -31,6 +31,19 @@ export {
|
|||
|
||||
export { renderTree, type RenderResult } from './renderer'
|
||||
|
||||
export {
|
||||
angularGradient,
|
||||
diamondGradient,
|
||||
gradient,
|
||||
linearGradient,
|
||||
radialGradient,
|
||||
solid,
|
||||
type GradientPaintOptions,
|
||||
type PaintColor,
|
||||
type PaintStop,
|
||||
type SolidPaintOptions
|
||||
} from './paints'
|
||||
|
||||
export { defineVars, designVar, isVariable, type DesignVariable, type VarDef } from './vars'
|
||||
|
||||
export { createElement } from './mini-react'
|
||||
|
|
|
|||
82
packages/core/src/design-jsx/paints.ts
Normal file
82
packages/core/src/design-jsx/paints.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { colorToFill, parseColor } from '#core/color'
|
||||
import { TRANSPARENT } from '#core/constants'
|
||||
import type { BlendMode, Fill, FillType, GradientStop, GradientTransform } from '#core/scene-graph'
|
||||
import type { Color } from '#core/types'
|
||||
|
||||
export type PaintColor = string | Color
|
||||
export type PaintStop = readonly [PaintColor, number] | { color: PaintColor; position: number }
|
||||
|
||||
export interface SolidPaintOptions {
|
||||
opacity?: number
|
||||
visible?: boolean
|
||||
blendMode?: BlendMode
|
||||
}
|
||||
|
||||
export interface GradientPaintOptions extends SolidPaintOptions {
|
||||
transform?: GradientTransform
|
||||
}
|
||||
|
||||
const DEFAULT_GRADIENT_TRANSFORM: GradientTransform = {
|
||||
m00: 1,
|
||||
m01: 0,
|
||||
m02: 0,
|
||||
m10: 0,
|
||||
m11: 1,
|
||||
m12: 0
|
||||
}
|
||||
|
||||
function toColor(color: PaintColor): Color {
|
||||
return typeof color === 'string' ? parseColor(color) : color
|
||||
}
|
||||
|
||||
function toStop(stop: PaintStop): GradientStop {
|
||||
if ('color' in stop) {
|
||||
return { color: toColor(stop.color), position: stop.position }
|
||||
}
|
||||
return { color: toColor(stop[0]), position: stop[1] }
|
||||
}
|
||||
|
||||
export function solid(color: PaintColor, options: SolidPaintOptions = {}): Fill {
|
||||
const fill = colorToFill(color)
|
||||
return {
|
||||
...fill,
|
||||
opacity: options.opacity ?? fill.opacity,
|
||||
visible: options.visible ?? true,
|
||||
blendMode: options.blendMode
|
||||
}
|
||||
}
|
||||
|
||||
export function gradient(
|
||||
type: Extract<
|
||||
FillType,
|
||||
'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND'
|
||||
>,
|
||||
stops: PaintStop[],
|
||||
options: GradientPaintOptions = {}
|
||||
): Fill {
|
||||
return {
|
||||
type,
|
||||
color: { ...TRANSPARENT },
|
||||
opacity: options.opacity ?? 1,
|
||||
visible: options.visible ?? true,
|
||||
blendMode: options.blendMode,
|
||||
gradientStops: stops.map(toStop),
|
||||
gradientTransform: options.transform ?? DEFAULT_GRADIENT_TRANSFORM
|
||||
}
|
||||
}
|
||||
|
||||
export function linearGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
|
||||
return gradient('GRADIENT_LINEAR', stops, options)
|
||||
}
|
||||
|
||||
export function radialGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
|
||||
return gradient('GRADIENT_RADIAL', stops, options)
|
||||
}
|
||||
|
||||
export function angularGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
|
||||
return gradient('GRADIENT_ANGULAR', stops, options)
|
||||
}
|
||||
|
||||
export function diamondGradient(stops: PaintStop[], options?: GradientPaintOptions): Fill {
|
||||
return gradient('GRADIENT_DIAMOND', stops, options)
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { colorToFill, parseColor } from '#core/color'
|
||||
import { TRANSPARENT } from '#core/constants'
|
||||
import type { GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
|
||||
import type { Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
|
||||
import type { Color, JsonObject } from '#core/types'
|
||||
|
||||
const WEIGHT_MAP: Record<string, number> = {
|
||||
|
|
@ -159,6 +159,24 @@ function applyFillSizing(
|
|||
}
|
||||
}
|
||||
|
||||
function isFill(value: unknown): value is Fill {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'type' in value &&
|
||||
'color' in value &&
|
||||
'visible' in value
|
||||
)
|
||||
}
|
||||
|
||||
function isFillValue(value: unknown): value is string | Color | Fill {
|
||||
return typeof value === 'string' || isColor(value) || isFill(value)
|
||||
}
|
||||
|
||||
function fillFromValue(value: string | Color | Fill): Fill {
|
||||
return isFill(value) ? structuredClone(value) : colorToFill(value)
|
||||
}
|
||||
|
||||
function isColor(value: unknown): value is Color {
|
||||
return (
|
||||
value !== null &&
|
||||
|
|
@ -171,8 +189,14 @@ function isColor(value: unknown): value is Color {
|
|||
}
|
||||
|
||||
function applyFillOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
if (Array.isArray(props.fills)) {
|
||||
const fills = props.fills.filter(isFillValue).map(fillFromValue)
|
||||
if (fills.length > 0) o.fills = fills
|
||||
return
|
||||
}
|
||||
|
||||
const bg = props.bg ?? props.fill ?? props.background ?? props.backgroundColor
|
||||
if (typeof bg === 'string' || isColor(bg)) o.fills = [colorToFill(bg)]
|
||||
if (isFillValue(bg)) o.fills = [fillFromValue(bg)]
|
||||
}
|
||||
|
||||
function applyStrokeOverride(props: Record<string, unknown>, o: Partial<SceneNode>): void {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types'
|
|||
import type { SceneGraph } from '#core/scene-graph'
|
||||
|
||||
import * as React from './mini-react'
|
||||
import {
|
||||
angularGradient,
|
||||
diamondGradient,
|
||||
gradient,
|
||||
linearGradient,
|
||||
radialGradient,
|
||||
solid
|
||||
} from './paints'
|
||||
import { renderTree, type RenderResult } from './renderer'
|
||||
import { isTreeNode, resolveToTree, type TreeNode } from './tree'
|
||||
|
||||
|
|
@ -50,6 +58,7 @@ const SUPPORTED_PROPS = new Set([
|
|||
'pl',
|
||||
'bg',
|
||||
'fill',
|
||||
'fills',
|
||||
'background',
|
||||
'backgroundColor',
|
||||
'stroke',
|
||||
|
|
@ -150,6 +159,12 @@ 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 solid = __helpers.solid
|
||||
const gradient = __helpers.gradient
|
||||
const linearGradient = __helpers.linearGradient
|
||||
const radialGradient = __helpers.radialGradient
|
||||
const angularGradient = __helpers.angularGradient
|
||||
const diamondGradient = __helpers.diamondGradient
|
||||
const __varSymbol = Symbol.for('open-pencil.variable')
|
||||
const designVar = (def, value) => typeof def === 'string'
|
||||
? ({ [__varSymbol]: true, id: def, name: def, value })
|
||||
|
|
@ -173,7 +188,14 @@ export function buildComponent(jsxString: string): React.ComponentType {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line typescript-eslint/no-implied-eval -- sucrase output must be evaluated at runtime
|
||||
return new Function('React', code)(React) as React.ComponentType
|
||||
return new Function('React', '__helpers', code)(React, {
|
||||
angularGradient,
|
||||
diamondGradient,
|
||||
gradient,
|
||||
linearGradient,
|
||||
radialGradient,
|
||||
solid
|
||||
}) as React.ComponentType
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -142,6 +142,15 @@ function preparePropsForRender(
|
|||
const props = { ...source }
|
||||
const bindings: Record<string, string> = {}
|
||||
|
||||
if (Array.isArray(props.fills)) {
|
||||
props.fills = props.fills.map((value, index) => {
|
||||
if (!isVariable(value)) return value
|
||||
const variableId = resolveVariableId(graph, value)
|
||||
if (variableId) bindings[`fills/${index}/color`] = variableId
|
||||
return variableFallback(graph, value) ?? value
|
||||
})
|
||||
}
|
||||
|
||||
for (const key of ['bg', 'fill', 'background', 'backgroundColor']) {
|
||||
bindVariableProp(graph, props, bindings, key, 'fills/0/color')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Fill } from '#core/scene-graph'
|
||||
import type { Color } from '#core/types'
|
||||
|
||||
import type { DesignVariable } from './vars'
|
||||
|
|
@ -85,7 +86,7 @@ export function node(
|
|||
return { type, props: rest, children: processed }
|
||||
}
|
||||
|
||||
export type PaintProp = string | Color | DesignVariable
|
||||
export type PaintProp = string | Color | Fill | DesignVariable
|
||||
|
||||
export type StyleProps = {
|
||||
flex?: 'row' | 'col' | 'column'
|
||||
|
|
@ -121,6 +122,7 @@ export type StyleProps = {
|
|||
|
||||
bg?: PaintProp
|
||||
fill?: PaintProp
|
||||
fills?: PaintProp[]
|
||||
stroke?: PaintProp
|
||||
strokeWidth?: number
|
||||
strokeAlign?: 'inside' | 'outside' | 'center'
|
||||
|
|
|
|||
|
|
@ -309,6 +309,12 @@ export {
|
|||
renderJSX,
|
||||
renderTreeNode,
|
||||
buildComponent,
|
||||
angularGradient,
|
||||
diamondGradient,
|
||||
gradient,
|
||||
linearGradient,
|
||||
radialGradient,
|
||||
solid,
|
||||
defineVars,
|
||||
designVar,
|
||||
isVariable,
|
||||
|
|
@ -340,6 +346,10 @@ export {
|
|||
type TextProps,
|
||||
type StyleProps,
|
||||
type PaintProp,
|
||||
type GradientPaintOptions,
|
||||
type PaintColor,
|
||||
type PaintStop,
|
||||
type SolidPaintOptions,
|
||||
type DesignVariable,
|
||||
type VarDef,
|
||||
type RenderResult,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import {
|
|||
ComponentSet,
|
||||
Instance,
|
||||
defineVars,
|
||||
designVar
|
||||
designVar,
|
||||
linearGradient,
|
||||
solid
|
||||
} from '@open-pencil/core'
|
||||
|
||||
import { expectDefined, getNodeOrThrow, childIdAt } from '#tests/helpers/assert'
|
||||
|
|
@ -38,6 +40,43 @@ describe('renderTree', () => {
|
|||
expect(expectDefined(node.fills[0], 'first fill').type).toBe('SOLID')
|
||||
})
|
||||
|
||||
it('renders structured fill helpers', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const result = await renderTree(
|
||||
g,
|
||||
Frame({
|
||||
name: 'Paints',
|
||||
w: 200,
|
||||
h: 100,
|
||||
fills: [
|
||||
solid('#112233'),
|
||||
linearGradient([
|
||||
['#ffffff', 0],
|
||||
['rgba(0, 0, 0, 0)', 1]
|
||||
])
|
||||
]
|
||||
})
|
||||
)
|
||||
const node = getNodeOrThrow(g, result.id)
|
||||
|
||||
expect(node.fills).toHaveLength(2)
|
||||
expect(node.fills[0]?.type).toBe('SOLID')
|
||||
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
|
||||
expect(node.fills[1]?.gradientStops).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('renders structured fill helpers from JSX strings', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const [result] = await renderJSX(
|
||||
g,
|
||||
`<Frame name="Paints" w={200} h={100} fills={[solid('#112233'), linearGradient([['#fff', 0], ['#0000', 1]])]} />`
|
||||
)
|
||||
const node = getNodeOrThrow(g, result.id)
|
||||
|
||||
expect(node.fills).toHaveLength(2)
|
||||
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
|
||||
})
|
||||
|
||||
it('renders text node with content', async () => {
|
||||
const g = makeSceneGraph()
|
||||
const tree = Text({
|
||||
|
|
|
|||
Loading…
Reference in a new issue