feat(design-jsx): support structured effects

This commit is contained in:
Danila Poyarkov 2026-06-02 13:38:29 +03:00
parent cc1167eed5
commit 8983c11f45
8 changed files with 167 additions and 2 deletions

View file

@ -7,6 +7,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 structured design JSX effect helpers for shadows and blur effects.
- 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.

View file

@ -0,0 +1,77 @@
import { parseColor } from '#core/color'
import { TRANSPARENT } from '#core/constants'
import type { BlendMode, Effect } from '#core/scene-graph'
import type { Color, Vector } from '#core/types'
export type EffectColor = string | Color
export interface ShadowEffectOptions {
color?: EffectColor
x?: number
y?: number
offset?: Vector
radius?: number
spread?: number
visible?: boolean
blendMode?: BlendMode
showShadowBehindNode?: boolean
}
export interface BlurEffectOptions {
radius?: number
visible?: boolean
}
function toColor(color: EffectColor | undefined): Color {
if (color === undefined) return { ...TRANSPARENT }
return typeof color === 'string' ? parseColor(color) : color
}
function shadowEffect(type: 'DROP_SHADOW' | 'INNER_SHADOW', options: ShadowEffectOptions): Effect {
return {
type,
color: toColor(options.color ?? 'rgba(0, 0, 0, 0.25)'),
offset: options.offset ?? { x: options.x ?? 0, y: options.y ?? 4 },
radius: options.radius ?? 8,
spread: options.spread ?? 0,
visible: options.visible ?? true,
blendMode: options.blendMode,
showShadowBehindNode: options.showShadowBehindNode
}
}
function blurEffect(
type: 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR',
radiusOrOptions: number | BlurEffectOptions = 8
): Effect {
const options =
typeof radiusOrOptions === 'number' ? { radius: radiusOrOptions } : radiusOrOptions
return {
type,
color: { ...TRANSPARENT },
offset: { x: 0, y: 0 },
radius: options.radius ?? 8,
spread: 0,
visible: options.visible ?? true
}
}
export function dropShadow(options: ShadowEffectOptions = {}): Effect {
return shadowEffect('DROP_SHADOW', options)
}
export function innerShadow(options: ShadowEffectOptions = {}): Effect {
return shadowEffect('INNER_SHADOW', options)
}
export function layerBlur(radiusOrOptions?: number | BlurEffectOptions): Effect {
return blurEffect('LAYER_BLUR', radiusOrOptions)
}
export function backgroundBlur(radiusOrOptions?: number | BlurEffectOptions): Effect {
return blurEffect('BACKGROUND_BLUR', radiusOrOptions)
}
export function foregroundBlur(radiusOrOptions?: number | BlurEffectOptions): Effect {
return blurEffect('FOREGROUND_BLUR', radiusOrOptions)
}

View file

@ -31,6 +31,17 @@ export {
export { renderTree, type RenderResult } from './renderer'
export {
backgroundBlur,
dropShadow,
foregroundBlur,
innerShadow,
layerBlur,
type BlurEffectOptions,
type EffectColor,
type ShadowEffectOptions
} from './effects'
export {
angularGradient,
diamondGradient,

View file

@ -1,6 +1,6 @@
import { colorToFill, parseColor } from '#core/color'
import { TRANSPARENT } from '#core/constants'
import type { Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
import type { Effect, Fill, GridTrack, LayoutMode, SceneNode, Stroke } from '#core/scene-graph'
import type { Color, JsonObject } from '#core/types'
const WEIGHT_MAP: Record<string, number> = {
@ -519,7 +519,22 @@ function applyTextOverrides(
applyTextAutoResize(props, o, parentLayout)
}
function isEffect(value: unknown): value is Effect {
return (
value !== null &&
typeof value === 'object' &&
'type' in value &&
'radius' in value &&
'visible' in value
)
}
function applyShapeAndEffectOverrides(props: Record<string, unknown>, o: Partial<SceneNode>): void {
if (Array.isArray(props.effects)) {
const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect))
if (effects.length > 0) o.effects = effects
}
if (props.points !== undefined) o.pointCount = props.points as number
if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number
if (props.pointCount !== undefined) o.pointCount = props.pointCount as number

View file

@ -3,6 +3,7 @@ import { transform } from 'sucrase'
import type { RenderOptions as RenderJSXOptions } from '#core/design-jsx/types'
import type { SceneGraph } from '#core/scene-graph'
import { backgroundBlur, dropShadow, foregroundBlur, innerShadow, layerBlur } from './effects'
import * as React from './mini-react'
import {
angularGradient,
@ -83,6 +84,7 @@ const SUPPORTED_PROPS = new Set([
'overflow',
'shadow',
'blur',
'effects',
'size',
'fontSize',
'font',
@ -159,6 +161,11 @@ 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 dropShadow = __helpers.dropShadow
const innerShadow = __helpers.innerShadow
const layerBlur = __helpers.layerBlur
const backgroundBlur = __helpers.backgroundBlur
const foregroundBlur = __helpers.foregroundBlur
const solid = __helpers.solid
const gradient = __helpers.gradient
const linearGradient = __helpers.linearGradient
@ -189,6 +196,11 @@ 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', '__helpers', code)(React, {
backgroundBlur,
dropShadow,
foregroundBlur,
innerShadow,
layerBlur,
angularGradient,
diamondGradient,
gradient,

View file

@ -1,4 +1,4 @@
import type { Fill } from '#core/scene-graph'
import type { Effect, Fill } from '#core/scene-graph'
import type { Color } from '#core/types'
import type { DesignVariable } from './vars'
@ -140,6 +140,7 @@ export type StyleProps = {
overflow?: 'hidden' | 'visible'
shadow?: string
blur?: number
effects?: Effect[]
size?: number
fontSize?: number

View file

@ -309,6 +309,11 @@ export {
renderJSX,
renderTreeNode,
buildComponent,
backgroundBlur,
dropShadow,
foregroundBlur,
innerShadow,
layerBlur,
angularGradient,
diamondGradient,
gradient,
@ -346,6 +351,9 @@ export {
type TextProps,
type StyleProps,
type PaintProp,
type BlurEffectOptions,
type EffectColor,
type ShadowEffectOptions,
type GradientPaintOptions,
type PaintColor,
type PaintStop,

View file

@ -17,6 +17,9 @@ import {
Instance,
defineVars,
designVar,
dropShadow,
innerShadow,
layerBlur,
linearGradient,
solid
} from '@open-pencil/core'
@ -77,6 +80,43 @@ describe('renderTree', () => {
expect(node.fills[1]?.type).toBe('GRADIENT_LINEAR')
})
it('renders structured effect helpers', async () => {
const g = makeSceneGraph()
const result = await renderTree(
g,
Frame({
name: 'Effects',
w: 200,
h: 100,
effects: [
dropShadow({ x: 0, y: 8, radius: 16 }),
innerShadow({ color: '#ff000080' }),
layerBlur(4)
]
})
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects).toHaveLength(3)
expect(node.effects[0]?.type).toBe('DROP_SHADOW')
expect(node.effects[0]?.offset.y).toBe(8)
expect(node.effects[1]?.type).toBe('INNER_SHADOW')
expect(node.effects[2]?.type).toBe('LAYER_BLUR')
})
it('renders structured effect helpers from JSX strings', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(
g,
`<Frame name="Effects" w={200} h={100} effects={[dropShadow({ x: 0, y: 8, radius: 16 }), backgroundBlur(12)]} />`
)
const node = getNodeOrThrow(g, result.id)
expect(node.effects).toHaveLength(2)
expect(node.effects[0]?.type).toBe('DROP_SHADOW')
expect(node.effects[1]?.type).toBe('BACKGROUND_BLUR')
})
it('renders text node with content', async () => {
const g = makeSceneGraph()
const tree = Text({