fix(fig): preserve paint variable overrides
- Carry paint bindings through symbol overrides and clone synchronization - Keep explicit instance strokes and resolved variable opacity intact
This commit is contained in:
parent
8c31d5ed87
commit
8dfed26d16
|
|
@ -66,7 +66,9 @@ function buildKiwiPropertyNodes(
|
|||
(nc.cornerRadius !== undefined || nc.rectangleCornerRadiiIndependent !== undefined) &&
|
||||
node.cornerRadius !== comp.cornerRadius
|
||||
const hasDiffVisible = nc.visible === false && comp.visible
|
||||
if (hasDiffRadius || hasDiffVisible) result.add(nodeId)
|
||||
const hasDiffFills = nc.fillPaints !== undefined && !isEqual(node.fills, comp.fills)
|
||||
const hasDiffStrokes = nc.strokePaints !== undefined && !isEqual(node.strokes, comp.strokes)
|
||||
if (hasDiffRadius || hasDiffVisible || hasDiffFills || hasDiffStrokes) result.add(nodeId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ export function applyOverridePatch(ctx: OverrideContext, patch: OverridePatch):
|
|||
const target = ctx.graph.getNode(patch.targetId)
|
||||
if (target) {
|
||||
const props = patch.props
|
||||
if (props.boundVariables) {
|
||||
props.boundVariables = { ...target.boundVariables, ...props.boundVariables }
|
||||
}
|
||||
preserveStrokeShapeProps(target, props)
|
||||
ctx.graph.preserveSourceMetadataDuring(() => ctx.graph.updateNode(patch.targetId, props))
|
||||
protectPatchProps(ctx.protectedFields, patch.targetId, props)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ import {
|
|||
mapArcData,
|
||||
importStyleRuns,
|
||||
convertStrokes,
|
||||
convertEffects
|
||||
convertEffects,
|
||||
extractBoundVariables
|
||||
} from '@open-pencil/fig/node-change'
|
||||
import type { NodeChange, Paint, Effect as KiwiEffect } from '@open-pencil/kiwi/fig/codec'
|
||||
import type { SceneNode, ArcData, TextAutoResize } from '@open-pencil/scene-graph'
|
||||
|
|
@ -31,6 +32,10 @@ function applyOverridePaints(ov: Record<string, unknown>, updates: Partial<Scene
|
|||
ov.strokeWeight as number | undefined,
|
||||
ov.strokeAlign as string | undefined
|
||||
)
|
||||
if (ov.fillPaints != null || ov.strokePaints != null) {
|
||||
const bindings = extractBoundVariables(ov as NodeChange)
|
||||
if (Object.keys(bindings).length > 0) updates.boundVariables = bindings
|
||||
}
|
||||
if (ov.effects != null) updates.effects = convertEffects(ov.effects as KiwiEffect[])
|
||||
if (ov.visible != null) updates.visible = ov.visible as boolean
|
||||
if (ov.opacity != null) updates.opacity = ov.opacity as number
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { omit } from 'es-toolkit/object'
|
||||
|
||||
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
|
||||
import {
|
||||
copyEffects,
|
||||
|
|
@ -94,10 +96,27 @@ function assignCopiedUpdate(
|
|||
}
|
||||
}
|
||||
|
||||
function syncPaintBindings(
|
||||
key: 'fills' | 'strokes',
|
||||
source: SceneNode,
|
||||
target: SceneNode,
|
||||
updates: Partial<SceneNode>
|
||||
): void {
|
||||
const prefix = `${key}/`
|
||||
const currentBindings = updates.boundVariables ?? target.boundVariables
|
||||
const paintFields = Object.keys(currentBindings).filter((field) => field.startsWith(prefix))
|
||||
const bindings: Record<string, string> = omit(currentBindings, paintFields)
|
||||
for (const [field, variableId] of Object.entries(source.boundVariables)) {
|
||||
if (field.startsWith(prefix)) bindings[field] = variableId
|
||||
}
|
||||
updates.boundVariables = bindings
|
||||
}
|
||||
|
||||
function copiedSync(key: CopiedSyncKey, field: ProtectedField): SyncFn {
|
||||
return (source, target, updates, protections) => {
|
||||
if (!hasSameCopySource(source[key], target[key]) && canSync(protections, target.id, field)) {
|
||||
assignCopiedUpdate(key, source, updates)
|
||||
if (key === 'fills' || key === 'strokes') syncPaintBindings(key, source, target, updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export interface InstanceNodeChange {
|
|||
componentPropDefs?: ComponentPropDef[]
|
||||
styleType?: string
|
||||
fillPaints?: NodeChange['fillPaints']
|
||||
strokePaints?: NodeChange['strokePaints']
|
||||
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||
strokeWeight?: number
|
||||
|
|
|
|||
|
|
@ -47,11 +47,21 @@ function resolveColorVar(paint: Paint): Color | undefined {
|
|||
return variableColorResolver(alias) ?? undefined
|
||||
}
|
||||
|
||||
function resolvedPaintColor(paint: Paint): { color: Color; opacity: number } {
|
||||
const resolved = resolveColorVar(paint)
|
||||
if (!resolved) return { color: convertColor(paint.color), opacity: paint.opacity ?? 1 }
|
||||
return {
|
||||
color: { ...resolved, a: paint.color?.a ?? 1 },
|
||||
opacity: paint.opacity ?? resolved.a
|
||||
}
|
||||
}
|
||||
|
||||
function convertBaseFill(p: Paint): Fill {
|
||||
const { color, opacity } = resolvedPaintColor(p)
|
||||
return {
|
||||
type: p.type as FillType,
|
||||
color: convertColor(resolveColorVar(p) ?? p.color),
|
||||
opacity: p.opacity ?? 1,
|
||||
color,
|
||||
opacity,
|
||||
visible: p.visible ?? true,
|
||||
blendMode: (p.blendMode ?? 'NORMAL') as BlendMode
|
||||
}
|
||||
|
|
@ -119,16 +129,19 @@ export function convertStrokes(
|
|||
if (align === 'INSIDE') strokeAlign = 'INSIDE'
|
||||
else if (align === 'OUTSIDE') strokeAlign = 'OUTSIDE'
|
||||
|
||||
return paints.map((p) => ({
|
||||
color: convertColor(resolveColorVar(p) ?? p.color),
|
||||
weight: weight ?? 1,
|
||||
opacity: p.opacity ?? 1,
|
||||
visible: p.visible ?? true,
|
||||
align: strokeAlign,
|
||||
cap: cap ?? 'NONE',
|
||||
join: join ?? 'MITER',
|
||||
dashPattern: dashPattern ?? []
|
||||
}))
|
||||
return paints.map((p) => {
|
||||
const { color, opacity } = resolvedPaintColor(p)
|
||||
return {
|
||||
color,
|
||||
weight: weight ?? 1,
|
||||
opacity,
|
||||
visible: p.visible ?? true,
|
||||
align: strokeAlign,
|
||||
cap: cap ?? 'NONE',
|
||||
join: join ?? 'MITER',
|
||||
dashPattern: dashPattern ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function convertEffects(effects?: KiwiEffect[]): Effect[] {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import {
|
|||
convertFontFeatures,
|
||||
convertLetterSpacing,
|
||||
convertLineHeight,
|
||||
convertStrokes,
|
||||
decodeVectorNetworkBlob,
|
||||
encodeVectorNetworkBlob,
|
||||
mapTextDecoration
|
||||
mapTextDecoration,
|
||||
setVariableColorResolver
|
||||
} from '../src/node-change'
|
||||
|
||||
describe('@open-pencil/fig NodeChange policy', () => {
|
||||
|
|
@ -45,6 +47,27 @@ describe('@open-pencil/fig NodeChange policy', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('keeps resolved variable alpha in paint opacity', () => {
|
||||
setVariableColorResolver(() => ({ r: 1, g: 0, b: 0, a: 0.4 }))
|
||||
try {
|
||||
const paint = {
|
||||
type: 'SOLID',
|
||||
color: { r: 0, g: 0, b: 0, a: 1 },
|
||||
colorVar: { value: { alias: { guid: { sessionID: 1, localID: 2 } } } }
|
||||
}
|
||||
expect(convertFills([paint])[0]).toMatchObject({
|
||||
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||
opacity: 0.4
|
||||
})
|
||||
expect(convertStrokes([paint])[0]).toMatchObject({
|
||||
color: { r: 1, g: 0, b: 0, a: 1 },
|
||||
opacity: 0.4
|
||||
})
|
||||
} finally {
|
||||
setVariableColorResolver(null)
|
||||
}
|
||||
})
|
||||
|
||||
test('round-trips vector network blobs with handle mirroring', () => {
|
||||
const network = {
|
||||
vertices: [
|
||||
|
|
|
|||
|
|
@ -341,6 +341,23 @@ describe('edge cases', () => {
|
|||
guidPath: { guids: [{ sessionID: 90, localID: 61 }] },
|
||||
overriddenSymbolID: { sessionID: 1, localID: 3 }
|
||||
},
|
||||
{
|
||||
guidPath: {
|
||||
guids: [
|
||||
{ sessionID: 90, localID: 61 },
|
||||
{ sessionID: 90, localID: 31 }
|
||||
]
|
||||
},
|
||||
fillPaints: [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color: { r: 1, g: 1, b: 1, a: 1 },
|
||||
colorVar: {
|
||||
value: { alias: { guid: { sessionID: 2, localID: 5 } } }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
guidPath: {
|
||||
guids: [
|
||||
|
|
@ -395,6 +412,9 @@ describe('edge cases', () => {
|
|||
const iconChildren = graph.getChildren(iconClone.id)
|
||||
expect(iconChildren).toHaveLength(3)
|
||||
expect(iconChildren.map((c) => c.name).sort()).toEqual(['Icon label', 'PathB1', 'PathB2'])
|
||||
expect(iconChildren.find((child) => child.name === 'PathB1')?.boundVariables).toMatchObject({
|
||||
'fills/0/color': '2:5'
|
||||
})
|
||||
expect(iconChildren.find((child) => child.name === 'Icon label')?.text).toBe(
|
||||
'Changed after swap'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { importNodeChanges } from '@open-pencil/core'
|
||||
import {
|
||||
protectField,
|
||||
syncNodeProps,
|
||||
type ProtectionMap
|
||||
} from '@open-pencil/fig/instance-overrides'
|
||||
import type { NodeChange } from '@open-pencil/kiwi/fig/codec'
|
||||
import { SceneGraph } from '@open-pencil/scene-graph'
|
||||
import type { Fill, Stroke } from '@open-pencil/scene-graph'
|
||||
|
||||
|
|
@ -51,15 +53,50 @@ const blueStroke: Stroke = {
|
|||
}
|
||||
|
||||
describe('fig import override field protection', () => {
|
||||
test('explicit instance strokes survive component synchronization', () => {
|
||||
const graph = importNodeChanges([
|
||||
{ guid: { sessionID: 0, localID: 0 }, type: 'DOCUMENT', name: 'Document' } as NodeChange,
|
||||
{
|
||||
guid: { sessionID: 0, localID: 1 },
|
||||
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
|
||||
type: 'CANVAS',
|
||||
name: 'Page'
|
||||
} as NodeChange,
|
||||
{
|
||||
guid: { sessionID: 1, localID: 1 },
|
||||
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '!' },
|
||||
type: 'SYMBOL',
|
||||
name: 'Button',
|
||||
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1 }]
|
||||
} as NodeChange,
|
||||
{
|
||||
guid: { sessionID: 1, localID: 2 },
|
||||
parentIndex: { guid: { sessionID: 0, localID: 1 }, position: '"' },
|
||||
type: 'INSTANCE',
|
||||
name: 'Button instance',
|
||||
symbolData: { symbolID: { sessionID: 1, localID: 1 } },
|
||||
strokePaints: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.4 }]
|
||||
} as NodeChange
|
||||
])
|
||||
|
||||
const instance = graph
|
||||
.getChildren(graph.getPages()[0].id)
|
||||
.find((node) => node.type === 'INSTANCE')
|
||||
expect(instance?.strokes[0]?.color).toEqual({ r: 1, g: 0, b: 0, a: 1 })
|
||||
expect(instance?.strokes[0]?.opacity).toBe(0.4)
|
||||
})
|
||||
|
||||
test('protected text still inherits fills', () => {
|
||||
const graph = new SceneGraph()
|
||||
const source = graph.createNode('TEXT', pageId(graph), {
|
||||
text: 'Source',
|
||||
fills: [redFill]
|
||||
fills: [redFill],
|
||||
boundVariables: { 'fills/0/color': 'source-color' }
|
||||
})
|
||||
const target = graph.createNode('TEXT', pageId(graph), {
|
||||
text: 'Override',
|
||||
fills: [blueFill]
|
||||
fills: [blueFill],
|
||||
boundVariables: { 'fills/0/color': 'target-color', width: 'target-width' }
|
||||
})
|
||||
const protections: ProtectionMap = new Map()
|
||||
protectField(protections, target.id, 'text')
|
||||
|
|
@ -69,6 +106,10 @@ describe('fig import override field protection', () => {
|
|||
const synced = graph.getNode(target.id)
|
||||
expect(synced?.text).toBe('Override')
|
||||
expect(synced?.fills[0]?.color).toEqual(redFill.color)
|
||||
expect(synced?.boundVariables).toEqual({
|
||||
'fills/0/color': 'source-color',
|
||||
width: 'target-width'
|
||||
})
|
||||
})
|
||||
|
||||
test('protected strokes still inherit visibility', () => {
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ const SPECS: FixtureSpec[] = [
|
|||
thumbnailHeight: 239,
|
||||
imageCount: 3,
|
||||
figKiwiVersion: 101,
|
||||
g1ExportSize: 596992,
|
||||
g2ExportSize: 596992
|
||||
g1ExportSize: 596973,
|
||||
g2ExportSize: 596973
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue