feat(design-jsx): support structured effects
This commit is contained in:
parent
cc1167eed5
commit
8983c11f45
|
|
@ -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.
|
||||
|
|
|
|||
77
packages/core/src/design-jsx/effects.ts
Normal file
77
packages/core/src/design-jsx/effects.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue