fix(figma-api): validate effects like Figma (#794)
* build(core): import Markdown with unplugin-raw Core inlined ?raw imports with a hand-written Rolldown plugin that also turned plain .md imports into strings, which nothing in Core uses. unplugin-raw already does this for the Vue SDK and design-jsx; use it here too and drop the unused *.md module declaration. * refactor(pen): use the scene-graph color parser pen/src/color.ts duplicated parseColor from @open-pencil/scene-graph/color line for line. Import it instead, which also drops pen's direct culori dependency. * fix(figma-api): validate effects like Figma The effects setter stored whatever a script passed, so scripts that Figma rejects ran here and malformed effects reached rendering and .fig export. Validate against Figma's effect shapes with Valibot, recorded from live Figma: strict objects, required shadow fields, radius >= 0, RGBA channels within 0..1, and no shadow fields on blurs. The getter now returns Figma's shape so node.effects = node.effects keeps working. Closes #786 * fix(figma-api): reject infinite numbers in effects Figma rejects Infinity in every effect number ("Number must be finite"), but v.number() accepts it, so infinite radii, offsets, and spreads reached the scene graph. * fix(figma-api): store PASS_THROUGH shadow blend as NORMAL PASS_THROUGH is a layer blend mode. Live Figma accepts it on drop and inner shadows but reads NORMAL back, so do the same instead of storing it.
This commit is contained in:
parent
8404cee664
commit
5ba5b4aad5
|
|
@ -28,6 +28,7 @@
|
|||
### Fixed
|
||||
- Type `parameterConsumptionMap`, `propRefValue`, and `expressionValue` in the Kiwi `NodeChange` codec, which `fig.kiwi` declares but the TypeScript definitions omitted, so reading them no longer needs a cast.
|
||||
|
||||
- Reject malformed effects assigned to `node.effects` in the plugin API with an error naming the invalid field, as Figma does, instead of storing them. `node.effects` now reads back in Figma's shape: layer blurs are `LAYER_BLUR` with `blurType`, and blurs no longer carry shadow fields (#786).
|
||||
- Render fragments (`<>…</>`) nested inside other elements in JSX from the AI and MCP `render` tool, which previously failed with `Unknown element: <>`.
|
||||
- Judge text contrast in the AI and MCP `describe` tool by its WCAG 2 ratio (4.5:1, or 3:1 for large text), the same ratio the `color-contrast` lint rule computes. It no longer reports passing dark text on mid-tone backgrounds as "dark on dark", now reports low-contrast light text, measures translucent and faded text as it is drawn, skips text whose color is bound to a variable, and says the ratio and the threshold it missed (#735).
|
||||
- Warn about options the paint and effect helpers ignore when rendering JSX instead of dropping them silently, and point `blur` in effect helpers at `radius`, the name Figma uses (#736).
|
||||
|
|
|
|||
5
bun.lock
5
bun.lock
|
|
@ -231,6 +231,7 @@
|
|||
"@types/opentype.js": "^1.3.10",
|
||||
"@types/rbush": "^4.0.0",
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-raw": "^0.7.0",
|
||||
},
|
||||
},
|
||||
"packages/design-jsx": {
|
||||
|
|
@ -368,12 +369,8 @@
|
|||
"packages/pen": {
|
||||
"name": "@open-pencil/pen",
|
||||
"version": "0.15.1",
|
||||
"dependencies": {
|
||||
"culori": "^4.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/culori": "^4.0.1",
|
||||
"tsdown": "^0.22.14",
|
||||
"typescript": "~5.8.3",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@
|
|||
"@types/diff": "^8.0.0",
|
||||
"@types/opentype.js": "^1.3.10",
|
||||
"@types/rbush": "^4.0.0",
|
||||
"typescript": "~5.8.3"
|
||||
"typescript": "~5.8.3",
|
||||
"unplugin-raw": "^0.7.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Effect, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
|
||||
import type { Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
|
||||
import { normalizeColor } from '@open-pencil/scene-graph/color'
|
||||
import { copyEffects, copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
|
||||
import { copyFills, copyStrokes } from '@open-pencil/scene-graph/copy'
|
||||
|
||||
import {
|
||||
raw,
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type NodeProxyInternals,
|
||||
type ProxyThis
|
||||
} from '#core/figma-api/accessor-utils'
|
||||
import { parseFigmaEffects, toFigmaEffect, type FigmaEffect } from '#core/figma-api/effects'
|
||||
|
||||
export function installVisualNodeProxyAccessors(
|
||||
prototype: object,
|
||||
|
|
@ -43,13 +44,11 @@ export function installVisualNodeProxyAccessors(
|
|||
}
|
||||
},
|
||||
effects: {
|
||||
get(this: ProxyThis): readonly Effect[] {
|
||||
return Object.freeze(copyEffects(raw(this, internals).effects))
|
||||
get(this: ProxyThis): readonly FigmaEffect[] {
|
||||
return Object.freeze(raw(this, internals).effects.map(toFigmaEffect))
|
||||
},
|
||||
set(this: ProxyThis, value: readonly Effect[]) {
|
||||
updateNode(this, internals, {
|
||||
effects: value.map((effect) => ({ ...effect, color: normalizeColor(effect.color) }))
|
||||
})
|
||||
set(this: ProxyThis, value: readonly FigmaEffect[]) {
|
||||
updateNode(this, internals, { effects: parseFigmaEffects(value) })
|
||||
}
|
||||
},
|
||||
opacity: {
|
||||
|
|
|
|||
|
|
@ -89,3 +89,7 @@ type InstancePropertySurfaceMatch = Expect<
|
|||
>
|
||||
|
||||
const _instancePropertySurfaceMatch: InstancePropertySurfaceMatch = true
|
||||
|
||||
// `Effect` here is Figma's plugin-typings union; OpenPencil reads and writes a subset of it.
|
||||
type EffectShapeMatch = Expect<Extends<FigmaNodeProxy['effects'][number], Effect>>
|
||||
const _effectShapeMatch: EffectShapeMatch = true
|
||||
|
|
|
|||
169
packages/core/src/figma-api/effects.ts
Normal file
169
packages/core/src/figma-api/effects.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { BlendMode, Effect } from '@open-pencil/scene-graph'
|
||||
|
||||
import { TRANSPARENT } from '#core/constants'
|
||||
|
||||
const BLEND_MODE_KEYS = {
|
||||
NORMAL: true,
|
||||
DARKEN: true,
|
||||
MULTIPLY: true,
|
||||
COLOR_BURN: true,
|
||||
LIGHTEN: true,
|
||||
SCREEN: true,
|
||||
COLOR_DODGE: true,
|
||||
OVERLAY: true,
|
||||
SOFT_LIGHT: true,
|
||||
HARD_LIGHT: true,
|
||||
DIFFERENCE: true,
|
||||
EXCLUSION: true,
|
||||
HUE: true,
|
||||
SATURATION: true,
|
||||
COLOR: true,
|
||||
LUMINOSITY: true,
|
||||
PASS_THROUGH: true
|
||||
} satisfies Record<BlendMode, true>
|
||||
|
||||
const BLEND_MODES = Object.keys(BLEND_MODE_KEYS) as BlendMode[]
|
||||
|
||||
/** Effect kinds Figma's plugin API accepts that OpenPencil does not model yet. */
|
||||
const UNSUPPORTED_EFFECT_TYPES = new Set(['NOISE', 'TEXTURE', 'GLASS', 'SHADER'])
|
||||
|
||||
// Figma rejects Infinity as well as NaN ("Number must be finite").
|
||||
const finite = v.pipe(v.number(), v.finite())
|
||||
const unit = v.pipe(finite, v.minValue(0), v.maxValue(1))
|
||||
const nonNegative = v.pipe(finite, v.minValue(0))
|
||||
|
||||
const color = v.strictObject({ r: unit, g: unit, b: unit, a: unit })
|
||||
const vector = v.strictObject({ x: finite, y: finite })
|
||||
// Variable bindings on effects are not supported; Figma reports an empty object.
|
||||
const boundVariables = v.optional(v.strictObject({}))
|
||||
|
||||
const shadowEntries = {
|
||||
color,
|
||||
offset: vector,
|
||||
radius: nonNegative,
|
||||
spread: v.optional(finite),
|
||||
visible: v.boolean(),
|
||||
blendMode: v.picklist(BLEND_MODES),
|
||||
boundVariables
|
||||
}
|
||||
|
||||
const effectSchema = v.variant('type', [
|
||||
v.strictObject({
|
||||
type: v.literal('DROP_SHADOW'),
|
||||
...shadowEntries,
|
||||
showShadowBehindNode: v.optional(v.boolean())
|
||||
}),
|
||||
v.strictObject({ type: v.literal('INNER_SHADOW'), ...shadowEntries }),
|
||||
v.strictObject({
|
||||
type: v.picklist(['LAYER_BLUR', 'BACKGROUND_BLUR']),
|
||||
radius: nonNegative,
|
||||
visible: v.boolean(),
|
||||
blurType: v.optional(v.literal('NORMAL'), 'NORMAL'),
|
||||
boundVariables
|
||||
})
|
||||
])
|
||||
|
||||
const effectsSchema = v.array(effectSchema)
|
||||
|
||||
/** An effect as Figma's plugin API reads and writes it (`Effect` in `@figma/plugin-typings`). */
|
||||
export type FigmaEffect = v.InferOutput<typeof effectSchema>
|
||||
|
||||
function issuePath(issue: v.BaseIssue<unknown>): string {
|
||||
return (issue.path ?? [])
|
||||
.map((item) => (typeof item.key === 'number' ? `[${item.key}]` : `.${String(item.key)}`))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function unsupportedEffect(value: unknown): string | null {
|
||||
if (!Array.isArray(value)) return null
|
||||
for (const [index, effect] of value.entries()) {
|
||||
const type: unknown = v.is(v.object({ type: v.unknown() }), effect) ? effect.type : undefined
|
||||
if (typeof type === 'string' && UNSUPPORTED_EFFECT_TYPES.has(type)) {
|
||||
return `${type} effects are not supported at [${index}].type`
|
||||
}
|
||||
if (v.is(v.object({ blurType: v.literal('PROGRESSIVE') }), effect)) {
|
||||
return `Progressive blur is not supported at [${index}].blurType`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check `value` against Figma's effect shapes and convert it to scene effects.
|
||||
* Throws with the first problem, as Figma's `effects` setter does.
|
||||
*/
|
||||
export function parseFigmaEffects(value: unknown): Effect[] {
|
||||
const unsupported = unsupportedEffect(value)
|
||||
if (unsupported) throw new Error(`Property "effects" failed validation: ${unsupported}`)
|
||||
const result = v.safeParse(effectsSchema, value)
|
||||
if (!result.success) {
|
||||
const [issue] = result.issues
|
||||
throw new Error(
|
||||
`Property "effects" failed validation: ${issue.message} at ${issuePath(issue) || 'effects'}`
|
||||
)
|
||||
}
|
||||
return result.output.map(toSceneEffect)
|
||||
}
|
||||
|
||||
function toSceneEffect(effect: FigmaEffect): Effect {
|
||||
if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') {
|
||||
return {
|
||||
type: effect.type,
|
||||
color: { ...effect.color },
|
||||
offset: { ...effect.offset },
|
||||
radius: effect.radius,
|
||||
spread: effect.spread ?? 0,
|
||||
visible: effect.visible,
|
||||
// Figma accepts PASS_THROUGH on shadows but stores NORMAL; it is a layer blend mode.
|
||||
blendMode: effect.blendMode === 'PASS_THROUGH' ? 'NORMAL' : effect.blendMode,
|
||||
...(effect.type === 'DROP_SHADOW' && effect.showShadowBehindNode !== undefined
|
||||
? { showShadowBehindNode: effect.showShadowBehindNode }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: effect.type,
|
||||
color: { ...TRANSPARENT },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: effect.radius,
|
||||
spread: 0,
|
||||
visible: effect.visible
|
||||
}
|
||||
}
|
||||
|
||||
/** A scene effect in the shape Figma's `effects` getter returns. */
|
||||
export function toFigmaEffect(effect: Effect): FigmaEffect {
|
||||
if (
|
||||
effect.type === 'LAYER_BLUR' ||
|
||||
effect.type === 'FOREGROUND_BLUR' ||
|
||||
effect.type === 'BACKGROUND_BLUR'
|
||||
) {
|
||||
return {
|
||||
// `.fig` files call a layer blur FOREGROUND_BLUR; the plugin API calls it LAYER_BLUR.
|
||||
type: effect.type === 'BACKGROUND_BLUR' ? 'BACKGROUND_BLUR' : 'LAYER_BLUR',
|
||||
visible: effect.visible,
|
||||
radius: effect.radius,
|
||||
boundVariables: {},
|
||||
blurType: 'NORMAL'
|
||||
}
|
||||
}
|
||||
const shadow = {
|
||||
visible: effect.visible,
|
||||
radius: effect.radius,
|
||||
boundVariables: {},
|
||||
color: { ...effect.color },
|
||||
offset: { ...effect.offset },
|
||||
spread: effect.spread,
|
||||
blendMode: effect.blendMode ?? 'NORMAL'
|
||||
}
|
||||
return effect.type === 'DROP_SHADOW'
|
||||
? {
|
||||
type: 'DROP_SHADOW',
|
||||
...shadow,
|
||||
// The renderer draws the shadow behind the node unless this is `false`.
|
||||
showShadowBehindNode: effect.showShadowBehindNode ?? true
|
||||
}
|
||||
: { type: 'INNER_SHADOW', ...shadow }
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ import {
|
|||
const noop = () => undefined
|
||||
|
||||
export { FigmaNodeProxy } from './proxy'
|
||||
export type { FigmaEffect } from './effects'
|
||||
export type {
|
||||
FigmaBooleanOperationNode,
|
||||
FigmaComponentNode,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type {
|
|||
NodeType,
|
||||
Fill,
|
||||
Stroke,
|
||||
Effect,
|
||||
LayoutMode
|
||||
} from '@open-pencil/scene-graph'
|
||||
import {
|
||||
|
|
@ -18,6 +17,7 @@ import type { OkHCLColor, OkHCLPayload } from '@open-pencil/scene-graph/color'
|
|||
import type { Rect } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { assertNodeEditable } from '#core/editor/capabilities'
|
||||
import type { FigmaEffect } from '#core/figma-api/effects'
|
||||
|
||||
import { installBasicNodeProxyAccessors } from './accessors/basic'
|
||||
import { installLayoutNodeProxyAccessors } from './accessors/layout'
|
||||
|
|
@ -78,7 +78,7 @@ export class FigmaNodeProxy {
|
|||
|
||||
declare fills: readonly Fill[]
|
||||
declare strokes: readonly Stroke[]
|
||||
declare effects: readonly Effect[]
|
||||
declare effects: readonly FigmaEffect[]
|
||||
declare opacity: number
|
||||
declare visible: boolean
|
||||
declare locked: boolean
|
||||
|
|
|
|||
5
packages/core/src/global.d.ts
vendored
5
packages/core/src/global.d.ts
vendored
|
|
@ -10,11 +10,6 @@ interface Window {
|
|||
queryLocalFonts?(): Promise<FontData[]>
|
||||
}
|
||||
|
||||
declare module '*.md' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
declare module '*?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as v from 'valibot'
|
||||
|
||||
import type { Effect } from '@open-pencil/scene-graph'
|
||||
import { parseColor } from '@open-pencil/scene-graph/color'
|
||||
|
||||
import { DEFAULT_SHADOW_COLOR, TRANSPARENT } from '#core/constants'
|
||||
import { DEFAULT_SHADOW_COLOR } from '#core/constants'
|
||||
import type { FigmaEffect } from '#core/figma-api/effects'
|
||||
import { toolNumber, nodeIdInput } from '#core/tools/input'
|
||||
import { defineTool, nodeNotFound } from '#core/tools/schema'
|
||||
|
||||
|
|
@ -32,18 +32,23 @@ export const setEffects = defineTool({
|
|||
const node = figma.getNodeById(args.id)
|
||||
if (!node) return nodeNotFound(args.id)
|
||||
|
||||
const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
|
||||
let color = { ...DEFAULT_SHADOW_COLOR }
|
||||
if (isBlur) color = { ...TRANSPARENT }
|
||||
else if (args.color) color = parseColor(args.color)
|
||||
const effect: Effect = {
|
||||
type: args.type as Effect['type'],
|
||||
visible: true,
|
||||
radius: args.radius,
|
||||
color,
|
||||
offset: { x: isBlur ? 0 : args.offset_x, y: isBlur ? 0 : args.offset_y },
|
||||
spread: isBlur ? 0 : args.spread
|
||||
}
|
||||
const effect: FigmaEffect =
|
||||
args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR'
|
||||
? {
|
||||
type: args.type === 'BACKGROUND_BLUR' ? 'BACKGROUND_BLUR' : 'LAYER_BLUR',
|
||||
radius: args.radius,
|
||||
visible: true,
|
||||
blurType: 'NORMAL'
|
||||
}
|
||||
: {
|
||||
type: args.type,
|
||||
color: args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR },
|
||||
offset: { x: args.offset_x, y: args.offset_y },
|
||||
radius: args.radius,
|
||||
spread: args.spread,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
|
||||
node.effects = [...node.effects, effect]
|
||||
return { id: args.id, effects: node.effects.length }
|
||||
|
|
|
|||
132
packages/core/tests/figma-api/effects.test.ts
Normal file
132
packages/core/tests/figma-api/effects.test.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { FigmaAPI } from '@open-pencil/core/figma-api'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
|
||||
// Accepted shapes, read-back values, and rejections were recorded by running the
|
||||
// same assignments against a rectangle in live Figma through figma-use.
|
||||
|
||||
const color = { r: 0, g: 0, b: 0, a: 0.25 }
|
||||
const shadow = {
|
||||
type: 'DROP_SHADOW',
|
||||
color,
|
||||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
} as const
|
||||
|
||||
function createRectangle(graph = new SceneGraph()) {
|
||||
return new FigmaAPI(graph).createRectangle()
|
||||
}
|
||||
|
||||
function assign(value: unknown) {
|
||||
const rect = createRectangle()
|
||||
// Scripts are untyped; the setter must check what it receives at runtime.
|
||||
Reflect.set(rect, 'effects', value)
|
||||
return rect
|
||||
}
|
||||
|
||||
describe('node.effects', () => {
|
||||
test('reads a drop shadow back with Figma defaults', () => {
|
||||
expect(assign([shadow]).effects).toEqual([
|
||||
{
|
||||
type: 'DROP_SHADOW',
|
||||
visible: true,
|
||||
radius: 8,
|
||||
boundVariables: {},
|
||||
color,
|
||||
offset: { x: 0, y: 4 },
|
||||
spread: 0,
|
||||
blendMode: 'NORMAL',
|
||||
showShadowBehindNode: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('keeps spread and showShadowBehindNode', () => {
|
||||
const [effect] = assign([{ ...shadow, spread: 2, showShadowBehindNode: false }]).effects
|
||||
expect(effect).toMatchObject({ spread: 2, showShadowBehindNode: false })
|
||||
})
|
||||
|
||||
test('stores PASS_THROUGH on shadows as NORMAL', () => {
|
||||
for (const type of ['DROP_SHADOW', 'INNER_SHADOW'] as const) {
|
||||
const [effect] = assign([{ ...shadow, type, blendMode: 'PASS_THROUGH' }]).effects
|
||||
expect(effect).toMatchObject({ type, blendMode: 'NORMAL' })
|
||||
}
|
||||
})
|
||||
|
||||
test('reads blurs back with blurType and without shadow fields', () => {
|
||||
expect(assign([{ type: 'LAYER_BLUR', radius: 4, visible: true }]).effects).toEqual([
|
||||
{ type: 'LAYER_BLUR', visible: true, radius: 4, boundVariables: {}, blurType: 'NORMAL' }
|
||||
])
|
||||
expect(
|
||||
assign([{ type: 'BACKGROUND_BLUR', blurType: 'NORMAL', radius: 4, visible: true }]).effects
|
||||
).toEqual([
|
||||
{ type: 'BACKGROUND_BLUR', visible: true, radius: 4, boundVariables: {}, blurType: 'NORMAL' }
|
||||
])
|
||||
})
|
||||
|
||||
test('reads a .fig foreground blur as a layer blur', () => {
|
||||
const graph = new SceneGraph()
|
||||
const rect = createRectangle(graph)
|
||||
graph.updateNode(rect.id, {
|
||||
effects: [
|
||||
{
|
||||
type: 'FOREGROUND_BLUR',
|
||||
color: { r: 0, g: 0, b: 0, a: 0 },
|
||||
offset: { x: 0, y: 0 },
|
||||
radius: 6,
|
||||
spread: 0,
|
||||
visible: true
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(rect.effects[0]).toMatchObject({ type: 'LAYER_BLUR', radius: 6 })
|
||||
})
|
||||
|
||||
test('accepts its own effects back', () => {
|
||||
const rect = assign([shadow, { type: 'LAYER_BLUR', radius: 4, visible: true }])
|
||||
const before = rect.effects
|
||||
rect.effects = rect.effects
|
||||
expect(rect.effects).toEqual(before)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['missing required fields', [{ type: 'DROP_SHADOW', blur: 12 }]],
|
||||
['a negative radius', [{ ...shadow, radius: -5 }]],
|
||||
['a NaN radius', [{ ...shadow, radius: Number.NaN }]],
|
||||
['an infinite radius', [{ ...shadow, radius: Number.POSITIVE_INFINITY }]],
|
||||
['an infinite offset', [{ ...shadow, offset: { x: Number.POSITIVE_INFINITY, y: 0 } }]],
|
||||
['an infinite spread', [{ ...shadow, spread: Number.NEGATIVE_INFINITY }]],
|
||||
['an infinite blur radius', [{ type: 'LAYER_BLUR', radius: Number.POSITIVE_INFINITY, visible: true }]],
|
||||
['an unknown type', [{ type: 'NOT_AN_EFFECT' }]],
|
||||
['a foreground blur', [{ type: 'FOREGROUND_BLUR', radius: 4, visible: true }]],
|
||||
['an unknown key', [{ ...shadow, foo: 1 }]],
|
||||
['a shadow without blendMode', [{ ...shadow, blendMode: undefined }]],
|
||||
['an unknown blendMode', [{ ...shadow, blendMode: 'NOPE' }]],
|
||||
['showShadowBehindNode on an inner shadow', [{ ...shadow, type: 'INNER_SHADOW', showShadowBehindNode: true }]],
|
||||
['shadow fields on a blur', [{ type: 'LAYER_BLUR', radius: 4, visible: true, color, offset: { x: 0, y: 0 }, spread: 0 }]],
|
||||
['a color without alpha', [{ ...shadow, color: { r: 0, g: 0, b: 0 } }]],
|
||||
['a color channel above 1', [{ ...shadow, color: { r: 2, g: 0, b: 0, a: 1 } }]],
|
||||
['an incomplete offset', [{ ...shadow, offset: { x: 0 } }]],
|
||||
['a single effect instead of an array', shadow]
|
||||
])('rejects %s without changing the node', (_name, value) => {
|
||||
const rect = assign([shadow])
|
||||
const before = rect.effects
|
||||
expect(() => Reflect.set(rect, 'effects', value)).toThrow('Property "effects" failed validation')
|
||||
expect(rect.effects).toEqual(before)
|
||||
})
|
||||
|
||||
test('names the invalid field', () => {
|
||||
expect(() => assign([{ ...shadow, radius: -5 }])).toThrow('at [0].radius')
|
||||
expect(() => assign([shadow, { ...shadow, foo: 1 }])).toThrow('[1]')
|
||||
})
|
||||
|
||||
test('reports Figma effects OpenPencil does not model yet', () => {
|
||||
expect(() => assign([{ type: 'NOISE' }])).toThrow('NOISE effects are not supported')
|
||||
expect(() =>
|
||||
assign([{ type: 'LAYER_BLUR', blurType: 'PROGRESSIVE', radius: 4, visible: true }])
|
||||
).toThrow('Progressive blur is not supported')
|
||||
})
|
||||
})
|
||||
|
|
@ -1,32 +1,16 @@
|
|||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { defineConfig } from 'tsdown'
|
||||
import type { Rolldown } from 'tsdown'
|
||||
import raw from 'unplugin-raw/rolldown'
|
||||
|
||||
const packageJSON = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
function rawText(): Rolldown.Plugin {
|
||||
return {
|
||||
name: 'raw-text',
|
||||
load(id) {
|
||||
if (id.endsWith('?raw')) {
|
||||
const path = id.slice(0, -'?raw'.length)
|
||||
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
|
||||
}
|
||||
},
|
||||
transform(code, id) {
|
||||
if (id.endsWith('.md')) {
|
||||
return { code: `export default ${JSON.stringify(code)}`, map: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||
plugins: [rawText()],
|
||||
// Prompts and the authoring reference import Markdown as text.
|
||||
plugins: [raw()],
|
||||
unbundle: true,
|
||||
platform: 'neutral',
|
||||
format: ['esm'],
|
||||
|
|
|
|||
|
|
@ -38,12 +38,8 @@
|
|||
"peerDependencies": {
|
||||
"@open-pencil/scene-graph": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"culori": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/culori": "^4.0.1",
|
||||
"tsdown": "^0.22.14",
|
||||
"typescript": "~5.8.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { converter, parse } from 'culori'
|
||||
|
||||
import { BLACK } from '@open-pencil/scene-graph/constants'
|
||||
import type { Color } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
const rgbConverter = converter('rgb')
|
||||
|
||||
export function parseColor(input: string): Color {
|
||||
const parsedColor = parse(input)
|
||||
const rgbColor = parsedColor ? rgbConverter(parsedColor) : null
|
||||
|
||||
return rgbColor
|
||||
? {
|
||||
r: rgbColor.r,
|
||||
g: rgbColor.g,
|
||||
b: rgbColor.b,
|
||||
a: parsedColor?.alpha ?? 1
|
||||
}
|
||||
: structuredClone(BLACK)
|
||||
}
|
||||
|
|
@ -20,11 +20,10 @@ import type {
|
|||
VariableType,
|
||||
VariableValue
|
||||
} from '@open-pencil/scene-graph'
|
||||
import { parseColor } from '@open-pencil/scene-graph/color'
|
||||
import { BLACK } from '@open-pencil/scene-graph/constants'
|
||||
import type { Vector } from '@open-pencil/scene-graph/primitives'
|
||||
|
||||
import { parseColor } from './color'
|
||||
|
||||
export interface PenDocument {
|
||||
version: string
|
||||
children: PenNode[]
|
||||
|
|
|
|||
|
|
@ -186,7 +186,8 @@ heavy('CLI tool operations via eval', () => {
|
|||
offset: { x: 0, y: 4 },
|
||||
radius: 8,
|
||||
spread: 0,
|
||||
visible: true
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}]
|
||||
return { count: f.effects.length, type: f.effects[0].type }
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"*/*/src/**/*.ts",
|
||||
"*/*/tests/**/*.ts",
|
||||
"../src/global.d.ts",
|
||||
"../src/markdown.d.ts",
|
||||
"../packages/core/src/global.d.ts",
|
||||
"../packages/vue/src/global.d.ts"
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue