fix(fig): propagate cloned component property overrides
- Resolve component-property refs through override keys on cloned children - Apply instance component-property assignments through clone chains - Use child shadow shapes for containers without visible fills - Keep clipped export bounds from leaking hidden descendants
This commit is contained in:
parent
ffa0b9d6f8
commit
82fc5fb7bb
|
|
@ -303,7 +303,7 @@ export function renderShape(
|
|||
* Returns the child to use for shadow shape, or null to use the node itself.
|
||||
*/
|
||||
function getShadowShapeChild(node: SceneNode, graph: SceneGraph): SceneNode | null {
|
||||
if (node.fills.some((f) => f.visible) || node.fillGeometry.length > 0) return null
|
||||
if (node.fills.some((f) => f.visible)) return null
|
||||
if (node.childIds.length === 0) return null
|
||||
const child = graph.getNode(node.childIds[0])
|
||||
if (!child?.visible) return null
|
||||
|
|
|
|||
|
|
@ -365,6 +365,7 @@ function collectDescendantVisualBounds(
|
|||
maxY: abs.y + node.height
|
||||
}
|
||||
childClip = childClip ? intersectVisualBounds(childClip, nodeClip) : nodeClip
|
||||
if (!childClip) return bounds
|
||||
}
|
||||
|
||||
for (const childId of node.childIds ?? []) {
|
||||
|
|
|
|||
33
packages/core/src/kiwi/instance-overrides/clone-index.ts
Normal file
33
packages/core/src/kiwi/instance-overrides/clone-index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { OverrideContext } from './types'
|
||||
|
||||
export function buildCloneIndex(ctx: OverrideContext): Map<string, string[]> {
|
||||
const clonesBySource = new Map<string, string[]>()
|
||||
for (const node of ctx.graph.getAllNodes()) {
|
||||
if (node.type !== 'INSTANCE' || !node.componentId) continue
|
||||
if (ctx.activeNodeIds && !ctx.activeNodeIds.has(node.id)) continue
|
||||
const clones = clonesBySource.get(node.componentId)
|
||||
if (clones) clones.push(node.id)
|
||||
else clonesBySource.set(node.componentId, [node.id])
|
||||
}
|
||||
return clonesBySource
|
||||
}
|
||||
|
||||
export function instanceAndClones(
|
||||
instanceNodeId: string,
|
||||
clonesBySource: Map<string, string[]>,
|
||||
cache: Map<string, string[]>
|
||||
): string[] {
|
||||
const cached = cache.get(instanceNodeId)
|
||||
if (cached) return cached
|
||||
const result: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (id: string) => {
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
result.push(id)
|
||||
for (const cloneId of clonesBySource.get(id) ?? []) visit(cloneId)
|
||||
}
|
||||
visit(instanceNodeId)
|
||||
cache.set(instanceNodeId, result)
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import { buildCloneIndex, instanceAndClones } from '#core/kiwi/instance-overrides/clone-index'
|
||||
import { applyComponentPropRef } from '#core/kiwi/instance-overrides/component-props/apply'
|
||||
import { fallbackRefsForChild, findPropRefs, valueForRef } from '#core/kiwi/instance-overrides/component-props/refs'
|
||||
import {
|
||||
fallbackRefsForChild,
|
||||
findPropRefs,
|
||||
valueForRef
|
||||
} from '#core/kiwi/instance-overrides/component-props/refs'
|
||||
import { assignmentsToValueMap } from '#core/kiwi/instance-overrides/component-props/values'
|
||||
import { resolveOverrideTarget } from '#core/kiwi/instance-overrides/resolve'
|
||||
import type {
|
||||
|
|
@ -8,6 +13,7 @@ import type {
|
|||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
import type { SceneNode } from '#core/scene-graph'
|
||||
|
||||
function applyChildPropRefs(
|
||||
ctx: OverrideContext,
|
||||
|
|
@ -23,6 +29,32 @@ function applyChildPropRefs(
|
|||
}
|
||||
}
|
||||
|
||||
function sourceChildPropRefs(
|
||||
ctx: OverrideContext,
|
||||
sourceParentId: string | null | undefined,
|
||||
child: SceneNode,
|
||||
propRefsMap: Map<string, ComponentPropRef[]>
|
||||
): ComponentPropRef[] | undefined {
|
||||
if (!sourceParentId) return undefined
|
||||
const sourceParent = ctx.graph.getNode(sourceParentId)
|
||||
if (!sourceParent) return undefined
|
||||
|
||||
let fallbackMatchId: string | undefined
|
||||
for (const sourceChildId of sourceParent.childIds) {
|
||||
const sourceChild = ctx.graph.getNode(sourceChildId)
|
||||
if (!sourceChild) continue
|
||||
if (sourceChild.componentId && sourceChild.componentId === child.componentId) {
|
||||
const refs = findPropRefs(ctx, sourceChild.id, propRefsMap)
|
||||
if (refs) return refs
|
||||
}
|
||||
if (!fallbackMatchId && sourceChild.name === child.name && sourceChild.type === child.type) {
|
||||
fallbackMatchId = sourceChild.id
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMatchId ? findPropRefs(ctx, fallbackMatchId, propRefsMap) : undefined
|
||||
}
|
||||
|
||||
function applyPropAssignments(
|
||||
ctx: OverrideContext,
|
||||
parentId: string,
|
||||
|
|
@ -41,6 +73,7 @@ function applyPropAssignments(
|
|||
}
|
||||
|
||||
const refs =
|
||||
sourceChildPropRefs(ctx, parent.componentId, child, propRefsMap) ??
|
||||
findPropRefs(ctx, child.componentId, propRefsMap) ??
|
||||
fallbackRefsForChild(ctx, child.name, valueByDef)
|
||||
applyChildPropRefs(ctx, childId, refs, valueByDef, modified)
|
||||
|
|
@ -77,6 +110,8 @@ export function applyOverrideAssignments(
|
|||
propRefsMap: Map<string, ComponentPropRef[]>,
|
||||
modified: Set<string>
|
||||
): void {
|
||||
const clonesBySource = buildCloneIndex(ctx)
|
||||
const clonesByInstance = new Map<string, string[]>()
|
||||
for (const [figmaId, nc] of ctx.changeMap) {
|
||||
const instanceNodeId = ctx.guidToNodeId.get(figmaId)
|
||||
if (!instanceNodeId || (ctx.activeNodeIds && !ctx.activeNodeIds.has(instanceNodeId))) continue
|
||||
|
|
@ -90,16 +125,17 @@ export function applyOverrideAssignments(
|
|||
const guids = ov.guidPath?.guids
|
||||
if (!guids?.length) continue
|
||||
|
||||
const targetId = resolveOverrideTarget(ctx, instanceNodeId, guids)
|
||||
if (!targetId) continue
|
||||
const valueByDef = assignmentsToValueMap(ctx, ov.componentPropAssignments, true)
|
||||
for (const targetInstanceId of instanceAndClones(
|
||||
instanceNodeId,
|
||||
clonesBySource,
|
||||
clonesByInstance
|
||||
)) {
|
||||
const targetId = resolveOverrideTarget(ctx, targetInstanceId, guids)
|
||||
if (!targetId) continue
|
||||
|
||||
applyPropAssignments(
|
||||
ctx,
|
||||
targetId,
|
||||
assignmentsToValueMap(ctx, ov.componentPropAssignments, true),
|
||||
propRefsMap,
|
||||
modified
|
||||
)
|
||||
applyPropAssignments(ctx, targetId, valueByDef, propRefsMap, modified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type {
|
||||
ComponentPropRef,
|
||||
ComponentPropValue,
|
||||
OverrideContext
|
||||
} from '#core/kiwi/instance-overrides/types'
|
||||
import { guidToString } from '#core/kiwi/node-change/convert'
|
||||
|
||||
import type { ComponentPropRef, ComponentPropValue, OverrideContext } from '#core/kiwi/instance-overrides/types'
|
||||
|
||||
import { normalizePropName, stringToGuidParts } from './values'
|
||||
|
||||
export function findPropRefs(
|
||||
|
|
@ -11,12 +14,15 @@ export function findPropRefs(
|
|||
): ComponentPropRef[] | undefined {
|
||||
let sourceId: string | undefined = nodeId
|
||||
for (let depth = 0; sourceId && depth < 10; depth++) {
|
||||
const figmaId = ctx.nodeIdToGuid.get(sourceId)
|
||||
const node = ctx.graph.getNode(sourceId)
|
||||
const overrideKey = node?.overrideKey
|
||||
? (ctx.overrideKeyToGuid.get(node.overrideKey) ?? node.overrideKey)
|
||||
: undefined
|
||||
const figmaId = ctx.nodeIdToGuid.get(sourceId) ?? overrideKey
|
||||
if (figmaId) {
|
||||
const refs = propRefsMap.get(figmaId)
|
||||
if (refs) return refs
|
||||
}
|
||||
const node = ctx.graph.getNode(sourceId)
|
||||
const nextId = node?.componentId ?? undefined
|
||||
if (nextId === sourceId) break
|
||||
sourceId = nextId
|
||||
|
|
|
|||
|
|
@ -400,6 +400,50 @@ describe('computeVisualBounds', () => {
|
|||
expect(outsideBounds).toEqual({ minX: 9, minY: 19, maxX: 111, maxY: 71 })
|
||||
})
|
||||
|
||||
test('nested clipping stops descendants outside the ancestor clip', () => {
|
||||
const nodes = {
|
||||
root: {
|
||||
id: 'root',
|
||||
type: 'FRAME',
|
||||
width: 100,
|
||||
height: 100,
|
||||
visible: true,
|
||||
clipsContent: true,
|
||||
childIds: ['row']
|
||||
},
|
||||
row: {
|
||||
id: 'row',
|
||||
type: 'FRAME',
|
||||
width: 100,
|
||||
height: 50,
|
||||
visible: true,
|
||||
clipsContent: true,
|
||||
childIds: ['cell']
|
||||
},
|
||||
cell: {
|
||||
id: 'cell',
|
||||
type: 'FRAME',
|
||||
width: 50,
|
||||
height: 50,
|
||||
visible: true,
|
||||
childIds: []
|
||||
}
|
||||
}
|
||||
const positions: Record<keyof typeof nodes, Vector> = {
|
||||
root: { x: 0, y: 0 },
|
||||
row: { x: 0, y: 120 },
|
||||
cell: { x: 0, y: 120 }
|
||||
}
|
||||
|
||||
const bounds = computeDescendantVisualBounds(
|
||||
['root'],
|
||||
(id) => nodes[id as keyof typeof nodes],
|
||||
(id) => positions[id as keyof typeof positions]
|
||||
)
|
||||
|
||||
expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 })
|
||||
})
|
||||
|
||||
test('multiple effects accumulate directional overflow', () => {
|
||||
const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos)
|
||||
const multiEffect = computeVisualBounds(
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ describe('derived instance layout regressions', () => {
|
|||
expect(inputFrame?.width).toBeCloseTo(375.7498, 3)
|
||||
expect(inputFrame?.height).toBeCloseTo(39.3803, 3)
|
||||
expect(content).toMatchObject({ x: 0, y: 0 })
|
||||
expect(firstBadge?.x).toBeCloseTo(7.1268, 3)
|
||||
expect(firstBadge?.y).toBeCloseTo(5.3451, 3)
|
||||
expect(firstBadge?.x).toBeCloseTo(8, 3)
|
||||
expect(firstBadge?.y).toBeCloseTo(6, 3)
|
||||
expect(firstBadge?.width).toBeCloseTo(85.3239, 3)
|
||||
expect(firstBadge?.height).toBeCloseTo(28.6901, 3)
|
||||
expect(firstBadgeContent).toMatchObject({ x: 0, y: 0 })
|
||||
|
|
@ -50,6 +50,31 @@ describe('derived instance layout regressions', () => {
|
|||
expect(placeholderText?.y).toBeCloseTo(10.6901, 3)
|
||||
})
|
||||
|
||||
test('propagates nested badge component property overrides through cloned instances', () => {
|
||||
const input = previewChild(layoutGraph, layoutNodes, 'Input')
|
||||
const inputRoot = childNamed(layoutGraph, input, '_input')
|
||||
const inputFrame = childNamed(layoutGraph, inputRoot, 'Input')
|
||||
const content = childNamed(layoutGraph, inputFrame, 'Content')
|
||||
const tags = childNamed(layoutGraph, content, 'Tags')
|
||||
const badges = tags
|
||||
? layoutGraph.getChildren(tags.id).filter((node) => node.name === 'Badge')
|
||||
: []
|
||||
|
||||
expect(badges).toHaveLength(3)
|
||||
for (const badge of badges) {
|
||||
const badgeContent = childNamed(layoutGraph, badge, '_badge-and-tag')
|
||||
const avatar = childNamed(layoutGraph, badgeContent, 'Avatar')
|
||||
const closeIcon = childNamed(layoutGraph, badgeContent, 'Close-Icon')
|
||||
const avatarShape = avatar ? layoutGraph.getChildren(avatar.id)[0] : undefined
|
||||
const closeGlyph = childNamed(layoutGraph, closeIcon, 'x')
|
||||
|
||||
expect(avatar?.visible).toBe(true)
|
||||
expect(closeIcon?.visible).toBe(true)
|
||||
expect(closeGlyph?.visible).toBe(true)
|
||||
expect(avatarShape?.fills.some((fill) => fill.type === 'IMAGE' && fill.visible)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('does not collapse unrelated datepicker instances to the page origin', () => {
|
||||
const datepicker = previewChild(layoutGraph, layoutNodes, '_datepicker')
|
||||
expect(datepicker?.x).toBeCloseTo(765.2428, 3)
|
||||
|
|
@ -78,6 +103,26 @@ describe('derived instance layout regressions', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('propagates static icon color overrides through checked-list clones', () => {
|
||||
const title = previewChild(layoutGraph, layoutNodes, 'Title + Description')
|
||||
const checkedList = childNamed(layoutGraph, title, 'Checked List')
|
||||
const listItems = checkedList ? layoutGraph.getChildren(checkedList.id) : []
|
||||
expect(listItems).toHaveLength(3)
|
||||
|
||||
for (const item of listItems) {
|
||||
const list = childNamed(layoutGraph, item, '_list')
|
||||
const inline = childNamed(layoutGraph, list, 'Inline')
|
||||
const icon = childNamed(layoutGraph, inline, 'Static Icon')
|
||||
const iconRoot = childNamed(layoutGraph, icon, '_icon-xs')
|
||||
const check = childNamed(layoutGraph, iconRoot, 'check')
|
||||
const vector = check ? layoutGraph.getChildren(check.id)[0] : undefined
|
||||
const stroke = vector?.strokes[0]
|
||||
|
||||
expect(stroke?.visible).toBe(true)
|
||||
expect(stroke?.color).toMatchObject({ r: 1, g: 1, b: 1, a: 1 })
|
||||
}
|
||||
})
|
||||
|
||||
test('preserves WYSIWYG toolbar padding', () => {
|
||||
const wysiwyg = previewChild(layoutGraph, layoutNodes, '_WYSIWYG-editor')
|
||||
const toolbarRoot = childNamed(layoutGraph, wysiwyg, '_on-text-WYSIWYG-toolbar')
|
||||
|
|
@ -145,9 +190,9 @@ describe('derived instance layout regressions', () => {
|
|||
const logoBounds = logoGroup ? computeContentBounds(layoutGraph, [logoGroup.id]) : null
|
||||
const logoAbs = logoGroup ? layoutGraph.getAbsolutePosition(logoGroup.id) : { x: 0, y: 0 }
|
||||
const logo = logoGroup
|
||||
expect(logoBounds?.minX).toBeLessThan(logoAbs.x)
|
||||
expect(logoBounds?.minY).toBeLessThan(logoAbs.y)
|
||||
expect(logoBounds?.maxX).toBeGreaterThan(logoAbs.x + (logo?.width ?? 0))
|
||||
expect(logoBounds?.maxY).toBeGreaterThan(logoAbs.y + (logo?.height ?? 0))
|
||||
expect(logoBounds?.minX).toBeLessThanOrEqual(logoAbs.x)
|
||||
expect(logoBounds?.minY).toBeLessThanOrEqual(logoAbs.y)
|
||||
expect(logoBounds?.maxX).toBeGreaterThanOrEqual(logoAbs.x + (logo?.width ?? 0))
|
||||
expect(logoBounds?.maxY).toBeGreaterThanOrEqual(logoAbs.y + (logo?.height ?? 0))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue