Merge branch 'master' into fix/mcp-headless-export
This commit is contained in:
commit
e3f0d2422d
|
|
@ -8,6 +8,7 @@ import type {
|
|||
Effect,
|
||||
LayoutMode,
|
||||
} from './scene-graph'
|
||||
import { normalizeColor } from './color'
|
||||
import type { Rect } from './types'
|
||||
import { copyFills, copyStrokes, copyEffects } from './copy'
|
||||
|
||||
|
|
@ -167,7 +168,13 @@ export class FigmaNodeProxy {
|
|||
}
|
||||
|
||||
set fills(v: readonly Fill[]) {
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { fills: [...v] })
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
|
||||
fills: v.map((f) => ({
|
||||
...f,
|
||||
color: normalizeColor(f.color),
|
||||
gradientStops: f.gradientStops?.map((s) => ({ ...s, color: normalizeColor(s.color) }))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
get strokes(): readonly Stroke[] {
|
||||
|
|
@ -175,7 +182,9 @@ export class FigmaNodeProxy {
|
|||
}
|
||||
|
||||
set strokes(v: readonly Stroke[]) {
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes: [...v] })
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
|
||||
strokes: v.map((s) => ({ ...s, color: normalizeColor(s.color) }))
|
||||
})
|
||||
}
|
||||
|
||||
get effects(): readonly Effect[] {
|
||||
|
|
@ -183,7 +192,9 @@ export class FigmaNodeProxy {
|
|||
}
|
||||
|
||||
set effects(v: readonly Effect[]) {
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { effects: [...v] })
|
||||
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], {
|
||||
effects: v.map((e) => ({ ...e, color: normalizeColor(e.color) }))
|
||||
})
|
||||
}
|
||||
|
||||
get opacity(): number {
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ export interface NodeChange {
|
|||
// Frame
|
||||
clipsContent?: boolean
|
||||
frameMaskDisabled?: boolean
|
||||
resizeToFit?: boolean
|
||||
// Vector
|
||||
vectorData?: unknown
|
||||
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
|
||||
|
|
|
|||
|
|
@ -64,38 +64,55 @@ export function populateAndApplyOverrides(
|
|||
guidToNodeId: Map<string, string>,
|
||||
blobs: Uint8Array[] = []
|
||||
): void {
|
||||
// Work-queue population: seed with empty instances, then discover newly
|
||||
// cloned nested instances by walking the subtree (avoids full graph scan per round).
|
||||
const populateQueue: string[] = []
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
|
||||
populateQueue.push(node.id)
|
||||
}
|
||||
}
|
||||
// Populate empty INSTANCE nodes from their source components. Instances
|
||||
// must be populated bottom-up: if an instance's source is itself an
|
||||
// unpopulated instance, populate the source first so cloned children
|
||||
// are complete.
|
||||
function ensurePopulated(nodeId: string, visiting: Set<string>): void {
|
||||
const node = graph.getNode(nodeId)
|
||||
if (!node || node.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) return
|
||||
if (visiting.has(nodeId)) return
|
||||
visiting.add(nodeId)
|
||||
|
||||
function collectEmptyInstances(parentId: string, out: string[]) {
|
||||
const parent = graph.getNode(parentId)
|
||||
if (!parent) return
|
||||
for (const childId of parent.childIds) {
|
||||
const comp = graph.getNode(node.componentId)
|
||||
if (!comp) return
|
||||
|
||||
// If the source is an unpopulated instance, populate it first
|
||||
if (comp.type === 'INSTANCE' && comp.componentId && comp.childIds.length === 0) {
|
||||
ensurePopulated(comp.id, visiting)
|
||||
}
|
||||
// Also ensure children of the source are populated (nested instances)
|
||||
for (const childId of comp.childIds) {
|
||||
const child = graph.getNode(childId)
|
||||
if (!child) continue
|
||||
if (child.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) {
|
||||
out.push(child.id)
|
||||
} else if (child.childIds.length > 0) {
|
||||
collectEmptyInstances(childId, out)
|
||||
if (child?.type === 'INSTANCE' && child.componentId && child.childIds.length === 0) {
|
||||
ensurePopulated(childId, visiting)
|
||||
}
|
||||
}
|
||||
|
||||
if (comp.childIds.length > 0 && node.childIds.length === 0) {
|
||||
graph.populateInstanceChildren(nodeId, node.componentId)
|
||||
}
|
||||
}
|
||||
|
||||
while (populateQueue.length > 0) {
|
||||
const nodeId = populateQueue.pop()
|
||||
if (!nodeId) continue
|
||||
const node = graph.getNode(nodeId)
|
||||
if (node?.type !== 'INSTANCE' || !node.componentId || node.childIds.length > 0) continue
|
||||
const comp = graph.getNode(node.componentId)
|
||||
if (comp && comp.childIds.length > 0) {
|
||||
graph.populateInstanceChildren(nodeId, node.componentId)
|
||||
collectEmptyInstances(nodeId, populateQueue)
|
||||
const visiting = new Set<string>()
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
|
||||
ensurePopulated(node.id, visiting)
|
||||
}
|
||||
}
|
||||
// Second pass: cloning may have introduced new empty instances not seen
|
||||
// in the first pass (nested clones). Repeat until stable.
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const node of graph.getAllNodes()) {
|
||||
if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) {
|
||||
const comp = graph.getNode(node.componentId)
|
||||
if (comp && comp.childIds.length > 0) {
|
||||
graph.populateInstanceChildren(node.id, node.componentId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -250,9 +267,17 @@ export function populateAndApplyOverrides(
|
|||
const node = graph.getNode(nodeId)
|
||||
if (node?.type !== 'INSTANCE') return
|
||||
|
||||
// Only rename when the current name matches the root component name
|
||||
// (i.e. it wasn't manually overridden by the user).
|
||||
const rootCompId = node.componentId ? getComponentRoot(node.componentId) : undefined
|
||||
const rootComp = rootCompId ? graph.getNode(rootCompId) : undefined
|
||||
for (const childId of Array.from(node.childIds)) graph.deleteNode(childId)
|
||||
graph.updateNode(nodeId, { componentId: compId })
|
||||
const comp = graph.getNode(compId)
|
||||
const updates: Partial<SceneNode> = { componentId: compId }
|
||||
if (comp?.name && rootComp?.name && node.name === rootComp.name) {
|
||||
updates.name = comp.name
|
||||
}
|
||||
graph.updateNode(nodeId, updates)
|
||||
if (comp && comp.childIds.length > 0) {
|
||||
graph.populateInstanceChildren(nodeId, compId)
|
||||
}
|
||||
|
|
@ -282,15 +307,24 @@ export function populateAndApplyOverrides(
|
|||
}
|
||||
}
|
||||
|
||||
// Also apply from cloned instance sources — after population, cloned
|
||||
// instances have componentId pointing to the original kiwi node
|
||||
// Walk the componentId chain to find a kiwi source with assignments.
|
||||
// Cloned instances may be several levels deep (clone of clone of …),
|
||||
// so a single-hop lookup is insufficient.
|
||||
if (!node.componentId) continue
|
||||
const sourceFigmaId = nodeIdToGuid.get(node.componentId)
|
||||
if (!sourceFigmaId) continue
|
||||
const assignments = assignmentSources.get(sourceFigmaId)
|
||||
if (!assignments) continue
|
||||
|
||||
applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap)
|
||||
let sourceId: string | undefined = node.componentId
|
||||
for (let depth = 0; sourceId && depth < 20; depth++) {
|
||||
const figmaId = nodeIdToGuid.get(sourceId)
|
||||
if (figmaId) {
|
||||
const assignments = assignmentSources.get(figmaId)
|
||||
if (assignments) {
|
||||
applyPropAssignments(node.id, assignmentsToValueMap(assignments), propRefsMap)
|
||||
break
|
||||
}
|
||||
}
|
||||
const n = graph.getNode(sourceId)
|
||||
if (!n?.componentId || n.componentId === sourceId) break
|
||||
sourceId = n.componentId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -506,6 +540,11 @@ export function populateAndApplyOverrides(
|
|||
propagateDsdChanges(dsdModified, dsdSizeSet)
|
||||
}
|
||||
|
||||
// Tracks INSTANCE nodes whose componentId was changed by a swap override.
|
||||
// Populated in applySymbolOverrides and recloneChildren, read in syncChildrenDeep
|
||||
// to propagate swaps transitively through clone chains.
|
||||
const swappedInstances = new Set<string>()
|
||||
|
||||
function applySymbolOverrides(): Set<string> {
|
||||
const overriddenNodes = new Set<string>()
|
||||
componentIdRoot.clear()
|
||||
|
|
@ -528,8 +567,12 @@ export function populateAndApplyOverrides(
|
|||
overriddenNodes.add(targetId)
|
||||
|
||||
if (ov.overriddenSymbolID) {
|
||||
const newCompId = guidToNodeId.get(guidToString(ov.overriddenSymbolID))
|
||||
if (newCompId) repopulateInstance(targetId, newCompId)
|
||||
const swapGuid = guidToString(ov.overriddenSymbolID)
|
||||
const newCompId = guidToNodeId.get(swapGuid)
|
||||
if (newCompId) {
|
||||
repopulateInstance(targetId, newCompId)
|
||||
swappedInstances.add(targetId)
|
||||
}
|
||||
}
|
||||
|
||||
const { guidPath: _, overriddenSymbolID: _s, componentPropAssignments: _c, ...fields } = ov
|
||||
|
|
@ -559,6 +602,19 @@ export function populateAndApplyOverrides(
|
|||
if (Object.keys(updates).length > 0) graph.updateNode(target.id, updates)
|
||||
}
|
||||
|
||||
function recloneChildren(srcChildId: string, tgtNode: SceneNode) {
|
||||
const srcChild = graph.getNode(srcChildId)
|
||||
if (!srcChild) return
|
||||
|
||||
for (const childId of [...tgtNode.childIds]) graph.deleteNode(childId)
|
||||
graph.updateNode(tgtNode.id, { name: srcChild.name, componentId: srcChild.componentId })
|
||||
syncNodeProps(srcChild, tgtNode)
|
||||
if (srcChild.childIds.length > 0) {
|
||||
graph.populateInstanceChildren(tgtNode.id, srcChildId)
|
||||
}
|
||||
swappedInstances.add(tgtNode.id)
|
||||
}
|
||||
|
||||
function syncChildrenDeep(sourceId: string, targetId: string, skip?: Set<string>) {
|
||||
const src = graph.getNode(sourceId)
|
||||
const tgt = graph.getNode(targetId)
|
||||
|
|
@ -569,6 +625,12 @@ export function populateAndApplyOverrides(
|
|||
const srcNode = graph.getNode(src.childIds[i])
|
||||
const tgtNode = graph.getNode(tgt.childIds[i])
|
||||
if (!srcNode || !tgtNode || srcNode.type !== tgtNode.type) continue
|
||||
|
||||
if (srcNode.type === 'INSTANCE' && swappedInstances.has(src.childIds[i]) && srcNode.componentId !== tgtNode.componentId) {
|
||||
recloneChildren(src.childIds[i], tgtNode)
|
||||
continue
|
||||
}
|
||||
|
||||
syncNodeProps(srcNode, tgtNode)
|
||||
syncChildrenDeep(src.childIds[i], tgt.childIds[i], skip)
|
||||
}
|
||||
|
|
@ -635,6 +697,8 @@ export function populateAndApplyOverrides(
|
|||
function propagateOverridesTransitively(seeds: Set<string>) {
|
||||
if (seeds.size === 0) return
|
||||
|
||||
// Stale after applySymbolOverrides changed componentIds via repopulateInstance
|
||||
componentIdRoot.clear()
|
||||
const clonesOf = buildClonesMap()
|
||||
const expandedSeeds = expandSeedsToParents(seeds)
|
||||
const needsSync = buildNeedsSyncSet(expandedSeeds, clonesOf)
|
||||
|
|
@ -666,11 +730,9 @@ export function populateAndApplyOverrides(
|
|||
}
|
||||
|
||||
// Order matters:
|
||||
// 1. symbolOverrides — set property values and swap instances
|
||||
// 2. transitive sync — propagate overrides through clone chains (may
|
||||
// repopulate INSTANCE children, wiping any earlier property changes)
|
||||
// 1. symbolOverrides — set property values and swap instances (kiwi + clones)
|
||||
// 2. transitive sync — propagate overrides through remaining clone chains
|
||||
// 3. componentProperties — toggle visibility / swap via prop assignments
|
||||
// (must run AFTER sync so repopulated children aren't lost)
|
||||
// 4. derivedSymbolData — apply Figma's pre-computed sizes last
|
||||
const overriddenNodes = applySymbolOverrides()
|
||||
|
||||
|
|
@ -678,7 +740,5 @@ export function populateAndApplyOverrides(
|
|||
|
||||
applyComponentProperties()
|
||||
|
||||
// DSD resolution runs AFTER overrides so guidPaths can reach children
|
||||
// of instance-swapped nodes (repopulateInstance replaces children).
|
||||
applyDerivedSymbolData()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -605,7 +605,7 @@ export function nodeChangeToProps(
|
|||
expanded: true,
|
||||
autoRename: (nc.autoRename ?? true) as boolean,
|
||||
boundVariables: extractBoundVariables(nc),
|
||||
clipsContent: nc.frameMaskDisabled === false,
|
||||
clipsContent: nc.frameMaskDisabled === false && nc.resizeToFit !== true,
|
||||
componentId: extractSymbolId(nc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,40 +101,35 @@ export function applyGradientFill(r: SkiaRenderer, fill: Fill, node: SceneNode):
|
|||
r.ck.TileMode.Clamp
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
} else if (fill.type === 'GRADIENT_RADIAL') {
|
||||
const cx = t.m02 * w
|
||||
const cy = t.m12 * h
|
||||
const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h)
|
||||
const shader = r.ck.Shader.MakeRadialGradient(
|
||||
[cx, cy],
|
||||
radius,
|
||||
colors,
|
||||
positions,
|
||||
r.ck.TileMode.Clamp
|
||||
} else if (fill.type === 'GRADIENT_RADIAL' || fill.type === 'GRADIENT_DIAMOND') {
|
||||
// Figma's gradientTransform maps gradient space (center 0.5,0.5, radius 0.5)
|
||||
// to the node's normalized [0,1] coordinate space. The full local matrix
|
||||
// converts to pixel coordinates: scale(w, h) * gradientTransform.
|
||||
const localMatrix = r.ck.Matrix.multiply(
|
||||
r.ck.Matrix.scaled(w, h),
|
||||
[t.m00, t.m01, t.m02, t.m10, t.m11, t.m12, 0, 0, 1]
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
} else if (fill.type === 'GRADIENT_ANGULAR') {
|
||||
const cx = t.m02 * w
|
||||
const cy = t.m12 * h
|
||||
const shader = r.ck.Shader.MakeSweepGradient(
|
||||
cx,
|
||||
cy,
|
||||
const shader = r.ck.Shader.MakeRadialGradient(
|
||||
[0.5, 0.5],
|
||||
0.5,
|
||||
colors,
|
||||
positions,
|
||||
r.ck.TileMode.Clamp,
|
||||
undefined
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
} else if (fill.type === 'GRADIENT_DIAMOND') {
|
||||
const cx = t.m02 * w
|
||||
const cy = t.m12 * h
|
||||
const radius = Math.sqrt(t.m00 * t.m00 + t.m10 * t.m10) * Math.max(w, h)
|
||||
const shader = r.ck.Shader.MakeRadialGradient(
|
||||
[cx, cy],
|
||||
radius,
|
||||
} else if (fill.type === 'GRADIENT_ANGULAR') {
|
||||
const localMatrix = r.ck.Matrix.multiply(
|
||||
r.ck.Matrix.scaled(w, h),
|
||||
[t.m00, t.m01, t.m02, t.m10, t.m11, t.m12, 0, 0, 1]
|
||||
)
|
||||
const shader = r.ck.Shader.MakeSweepGradient(
|
||||
0.5,
|
||||
0.5,
|
||||
colors,
|
||||
positions,
|
||||
r.ck.TileMode.Clamp
|
||||
r.ck.TileMode.Clamp,
|
||||
localMatrix
|
||||
)
|
||||
r.fillPaint.setShader(shader)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,9 @@ export function renderNode(
|
|||
canvas.saveLayer(r.opacityPaint)
|
||||
}
|
||||
|
||||
const layerBlur = node.effects.find((e) => e.visible && e.type === 'LAYER_BLUR')
|
||||
const layerBlur = node.effects.find(
|
||||
(e) => e.visible && (e.type === 'LAYER_BLUR' || e.type === 'FOREGROUND_BLUR')
|
||||
)
|
||||
if (layerBlur) {
|
||||
r.effectLayerPaint.setImageFilter(r.getCachedBlur(layerBlur.radius / 2))
|
||||
canvas.saveLayer(r.effectLayerPaint)
|
||||
|
|
@ -443,10 +445,7 @@ export function renderEffects(
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(pass === 'behind' && effect.type === 'BACKGROUND_BLUR') ||
|
||||
(pass === 'front' && effect.type === 'FOREGROUND_BLUR')
|
||||
) {
|
||||
if (pass === 'behind' && effect.type === 'BACKGROUND_BLUR') {
|
||||
r.applyClippedBlur(canvas, node, rect, hasRadius, effect.radius / 2)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,31 @@ test('fill item shows color swatch', async () => {
|
|||
await expect(swatch).toBeVisible()
|
||||
})
|
||||
|
||||
test('clicking color area changes fill color', async () => {
|
||||
const id = await getSelectedId()
|
||||
const before = await getNode(id!)
|
||||
|
||||
const swatch = fillSection().locator('[data-test-id="fill-picker-swatch"]').first()
|
||||
await swatch.click()
|
||||
|
||||
const colorArea = page.locator('.cursor-crosshair').first()
|
||||
await expect(colorArea).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const box = await colorArea.boundingBox()
|
||||
await page.mouse.click(box!.x + box!.width - 10, box!.y + 10)
|
||||
await canvas.waitForRender()
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const after = await getNode(id!)
|
||||
const c1 = before!.fills[0].color
|
||||
const c2 = after!.fills[0].color
|
||||
expect(c1.r !== c2.r || c1.g !== c2.g || c1.b !== c2.b).toBe(true)
|
||||
|
||||
// Close popover — click the swatch again to toggle it off
|
||||
await swatch.click()
|
||||
await canvas.waitForRender()
|
||||
})
|
||||
|
||||
test('adding a stroke creates stroke section item', async () => {
|
||||
const addBtn = strokeSection().locator('[data-test-id="stroke-section-add"]')
|
||||
await addBtn.click()
|
||||
|
|
|
|||
|
|
@ -391,3 +391,32 @@ describe('fig-import: component set detection', () => {
|
|||
expect(frame.type).toBe('FRAME')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fig-import: clipsContent with resizeToFit', () => {
|
||||
test('regular FRAME with frameMaskDisabled=false clips content', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(), canvas(),
|
||||
node('FRAME', 10, 1, { frameMaskDisabled: false }),
|
||||
])
|
||||
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
||||
expect(frame.clipsContent).toBe(true)
|
||||
})
|
||||
|
||||
test('resizeToFit FRAME does not clip even with frameMaskDisabled=false', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(), canvas(),
|
||||
node('FRAME', 10, 1, { frameMaskDisabled: false, resizeToFit: true }),
|
||||
])
|
||||
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
||||
expect(frame.clipsContent).toBe(false)
|
||||
})
|
||||
|
||||
test('FRAME with frameMaskDisabled=true does not clip', () => {
|
||||
const graph = importNodeChanges([
|
||||
doc(), canvas(),
|
||||
node('FRAME', 10, 1, { frameMaskDisabled: true }),
|
||||
])
|
||||
const frame = graph.getChildren(graph.getPages()[0].id)[0]
|
||||
expect(frame.clipsContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
241
tests/engine/instance-overrides.test.ts
Normal file
241
tests/engine/instance-overrides.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
import { importNodeChanges, type NodeChange } from '@open-pencil/core'
|
||||
|
||||
const ID = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }
|
||||
const SIZE = { x: 100, y: 100 }
|
||||
|
||||
function nc(
|
||||
sessionID: number,
|
||||
localID: number,
|
||||
type: string,
|
||||
parentSessionID: number,
|
||||
parentLocalID: number,
|
||||
name: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
): NodeChange {
|
||||
return {
|
||||
guid: { sessionID, localID },
|
||||
parentIndex: {
|
||||
guid: { sessionID: parentSessionID, localID: parentLocalID },
|
||||
position: String.fromCharCode(33 + localID),
|
||||
},
|
||||
type,
|
||||
name,
|
||||
visible: true,
|
||||
opacity: 1,
|
||||
phase: 'CREATED',
|
||||
size: SIZE,
|
||||
transform: ID,
|
||||
...extra,
|
||||
} as NodeChange
|
||||
}
|
||||
|
||||
function guid(s: number, l: number) {
|
||||
return { sessionID: s, localID: l }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal fig structure for testing instance swap overrides:
|
||||
*
|
||||
* Document (0:0)
|
||||
* ├── InternalPage (0:1) [internalOnly]
|
||||
* │ ├── IconA COMPONENT (0:10) — children: VectorA (0:11)
|
||||
* │ ├── IconB COMPONENT (0:20) — children: VectorB (0:21)
|
||||
* │ └── Button COMPONENT (0:30) — children: Icon INSTANCE→IconA (0:31)
|
||||
* └── Page1 (0:2)
|
||||
* └── ButtonInstance INSTANCE→Button (0:40)
|
||||
* symbolOverride: swap Icon to IconB
|
||||
*/
|
||||
function swapOverrideFixture(opts?: { customName?: string }): NodeChange[] {
|
||||
return [
|
||||
nc(0, 0, 'DOCUMENT', 0, 0, 'Document'),
|
||||
nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }),
|
||||
nc(0, 2, 'CANVAS', 0, 0, 'Page1'),
|
||||
|
||||
// IconA component with a vector child
|
||||
nc(0, 10, 'COMPONENT', 0, 1, 'IconA'),
|
||||
nc(0, 11, 'VECTOR', 0, 10, 'VectorA'),
|
||||
|
||||
// IconB component with a vector child
|
||||
nc(0, 20, 'COMPONENT', 0, 1, 'IconB'),
|
||||
nc(0, 21, 'VECTOR', 0, 20, 'VectorB'),
|
||||
|
||||
// Button component containing an Icon instance pointing to IconA
|
||||
nc(0, 30, 'COMPONENT', 0, 1, 'Button'),
|
||||
nc(0, 31, 'INSTANCE', 0, 30, opts?.customName ?? 'IconA', {
|
||||
symbolData: { symbolID: guid(0, 10) },
|
||||
}),
|
||||
|
||||
// Visible-page instance of Button with a swap override: Icon→IconB
|
||||
nc(0, 40, 'INSTANCE', 0, 2, 'ButtonInstance', {
|
||||
symbolData: {
|
||||
symbolID: guid(0, 30),
|
||||
symbolOverrides: [
|
||||
{
|
||||
guidPath: { guids: [guid(0, 31)] },
|
||||
overriddenSymbolID: guid(0, 20),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
describe('instance swap overrides', () => {
|
||||
test('swap override renames icon and reclones children', () => {
|
||||
const graph = importNodeChanges(swapOverrideFixture())
|
||||
const page = graph.getPages().find((p) => p.name === 'Page1')!
|
||||
const button = graph.getChildren(page.id)[0]
|
||||
expect(button.name).toBe('ButtonInstance')
|
||||
|
||||
const icon = graph.getChildren(button.id)[0]
|
||||
expect(icon.name).toBe('IconB')
|
||||
expect(icon.type).toBe('INSTANCE')
|
||||
|
||||
const iconChildren = graph.getChildren(icon.id)
|
||||
expect(iconChildren.length).toBe(1)
|
||||
expect(iconChildren[0].name).toBe('VectorB')
|
||||
})
|
||||
|
||||
test('swap override preserves user-given name', () => {
|
||||
const graph = importNodeChanges(swapOverrideFixture({ customName: 'MyCustomIcon' }))
|
||||
const page = graph.getPages().find((p) => p.name === 'Page1')!
|
||||
const button = graph.getChildren(page.id)[0]
|
||||
const icon = graph.getChildren(button.id)[0]
|
||||
|
||||
// Name was "MyCustomIcon" which doesn't match root component "IconA",
|
||||
// so it should NOT be renamed to "IconB"
|
||||
expect(icon.name).toBe('MyCustomIcon')
|
||||
|
||||
// Children should still be swapped to IconB's children
|
||||
const iconChildren = graph.getChildren(icon.id)
|
||||
expect(iconChildren.length).toBe(1)
|
||||
expect(iconChildren[0].name).toBe('VectorB')
|
||||
})
|
||||
|
||||
test('swap propagates transitively through 2-level clone chain', () => {
|
||||
// Add a second instance that clones the first button instance
|
||||
const nodes = [
|
||||
...swapOverrideFixture(),
|
||||
// Wrapper COMPONENT on internal page that contains a Button instance
|
||||
nc(0, 50, 'COMPONENT', 0, 1, 'Wrapper'),
|
||||
nc(0, 51, 'INSTANCE', 0, 50, 'ButtonInstance', {
|
||||
symbolData: { symbolID: guid(0, 30) },
|
||||
}),
|
||||
// WrapperInstance on visible page with swap override on the button's icon
|
||||
nc(0, 60, 'INSTANCE', 0, 2, 'WrapperInstance', {
|
||||
symbolData: {
|
||||
symbolID: guid(0, 50),
|
||||
symbolOverrides: [
|
||||
{
|
||||
guidPath: { guids: [guid(0, 51), guid(0, 31)] },
|
||||
overriddenSymbolID: guid(0, 20),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
const graph = importNodeChanges(nodes)
|
||||
const page = graph.getPages().find((p) => p.name === 'Page1')!
|
||||
const children = graph.getChildren(page.id)
|
||||
|
||||
const wrapper = children.find((c) => c.name === 'WrapperInstance')!
|
||||
expect(wrapper).toBeDefined()
|
||||
|
||||
// WrapperInstance > ButtonInstance > icon
|
||||
const wrapperButton = graph.getChildren(wrapper.id)[0]
|
||||
expect(wrapperButton.name).toBe('ButtonInstance')
|
||||
|
||||
const icon = graph.getChildren(wrapperButton.id)[0]
|
||||
expect(icon.name).toBe('IconB')
|
||||
|
||||
const iconChildren = graph.getChildren(icon.id)
|
||||
expect(iconChildren.length).toBe(1)
|
||||
expect(iconChildren[0].name).toBe('VectorB')
|
||||
})
|
||||
|
||||
test('swap propagates to sibling clone at same level', () => {
|
||||
// Two Button instances on the visible page, both cloning the same component.
|
||||
// Only one has a swap override — the other should keep the default icon.
|
||||
const nodes: NodeChange[] = [
|
||||
...swapOverrideFixture(),
|
||||
// Second ButtonInstance with NO swap override
|
||||
nc(0, 41, 'INSTANCE', 0, 2, 'ButtonDefault', {
|
||||
symbolData: { symbolID: guid(0, 30) },
|
||||
}),
|
||||
]
|
||||
|
||||
const graph = importNodeChanges(nodes)
|
||||
const page = graph.getPages().find((p) => p.name === 'Page1')!
|
||||
const children = graph.getChildren(page.id)
|
||||
|
||||
const swapped = children.find((c) => c.name === 'ButtonInstance')!
|
||||
const swappedIcon = graph.getChildren(swapped.id)[0]
|
||||
expect(swappedIcon.name).toBe('IconB')
|
||||
|
||||
const defaultBtn = children.find((c) => c.name === 'ButtonDefault')!
|
||||
const defaultIcon = graph.getChildren(defaultBtn.id)[0]
|
||||
expect(defaultIcon.name).toBe('IconA')
|
||||
})
|
||||
|
||||
test('transitive propagation: clone of swapped clone gets swap', () => {
|
||||
// ButtonInstance (swap Icon→IconB) on internal page,
|
||||
// then ButtonClone on visible page clones ButtonInstance.
|
||||
// ButtonClone's icon should also be IconB.
|
||||
const nodes: NodeChange[] = [
|
||||
nc(0, 0, 'DOCUMENT', 0, 0, 'Document'),
|
||||
nc(0, 1, 'CANVAS', 0, 0, 'InternalPage', { internalOnly: true }),
|
||||
nc(0, 2, 'CANVAS', 0, 0, 'Page1'),
|
||||
|
||||
nc(0, 10, 'COMPONENT', 0, 1, 'IconA'),
|
||||
nc(0, 11, 'VECTOR', 0, 10, 'VectorA'),
|
||||
nc(0, 20, 'COMPONENT', 0, 1, 'IconB'),
|
||||
nc(0, 21, 'VECTOR', 0, 20, 'VectorB'),
|
||||
|
||||
// Button component: children = Icon INSTANCE→IconA
|
||||
nc(0, 30, 'COMPONENT', 0, 1, 'Button'),
|
||||
nc(0, 31, 'INSTANCE', 0, 30, 'IconA', {
|
||||
symbolData: { symbolID: guid(0, 10) },
|
||||
}),
|
||||
|
||||
// ButtonSwapped: instance of Button on internal page with swap
|
||||
nc(0, 40, 'INSTANCE', 0, 1, 'ButtonSwapped', {
|
||||
symbolData: {
|
||||
symbolID: guid(0, 30),
|
||||
symbolOverrides: [
|
||||
{
|
||||
guidPath: { guids: [guid(0, 31)] },
|
||||
overriddenSymbolID: guid(0, 20),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
// Container component on internal page wrapping ButtonSwapped
|
||||
nc(0, 50, 'COMPONENT', 0, 1, 'Container'),
|
||||
nc(0, 51, 'INSTANCE', 0, 50, 'ButtonSwapped', {
|
||||
symbolData: { symbolID: guid(0, 40) },
|
||||
}),
|
||||
|
||||
// Visible-page instance of Container
|
||||
nc(0, 60, 'INSTANCE', 0, 2, 'ContainerInstance', {
|
||||
symbolData: { symbolID: guid(0, 50) },
|
||||
}),
|
||||
]
|
||||
|
||||
const graph = importNodeChanges(nodes)
|
||||
const page = graph.getPages().find((p) => p.name === 'Page1')!
|
||||
const container = graph.getChildren(page.id)[0]
|
||||
|
||||
// ContainerInstance > ButtonSwapped > icon
|
||||
const button = graph.getChildren(container.id)[0]
|
||||
const icon = graph.getChildren(button.id)[0]
|
||||
expect(icon.name).toBe('IconB')
|
||||
|
||||
const iconChildren = graph.getChildren(icon.id)
|
||||
expect(iconChildren.length).toBe(1)
|
||||
expect(iconChildren[0].name).toBe('VectorB')
|
||||
})
|
||||
})
|
||||
|
|
@ -44,8 +44,8 @@ describe('Renderer handles all effect types', () => {
|
|||
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
|
||||
})
|
||||
|
||||
test('handles FOREGROUND_BLUR', () => {
|
||||
expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'")
|
||||
test('handles FOREGROUND_BLUR as layer blur', () => {
|
||||
expect(rendererSource).toContain("e.type === 'FOREGROUND_BLUR'")
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -107,9 +107,8 @@ describe('Blur effects use saveLayer pattern', () => {
|
|||
expect(layerBlurSection).toContain('saveLayer')
|
||||
})
|
||||
|
||||
test('background and foreground blur use applyClippedBlur', () => {
|
||||
test('background blur uses applyClippedBlur', () => {
|
||||
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
|
||||
expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'")
|
||||
expect(rendererSource).toContain('applyClippedBlur')
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue