feat(ai): add phone mockup placeholder label support in design generator
- Allow one centered text label inside phone placeholder frames instead of stripping all children - Add Fix 8.5: migrate sibling placeholder labels into the closest phone frame - Add phone label detection, creation, and normalization utilities - Update screenshot heuristic to preserve normalized label content
This commit is contained in:
parent
4367d2613a
commit
4f0f52e75a
|
|
@ -316,8 +316,9 @@ export function insertStreamingNode(
|
|||
|
||||
applyGenerationHeuristics(node)
|
||||
|
||||
// Skip ALL children being streamed into a phone placeholder — it must stay empty.
|
||||
// Also skip if the parent node doesn't exist on canvas (was itself blocked as a phone child).
|
||||
// Skip AI-streamed children under phone placeholders. Placeholder internals are
|
||||
// normalized post-streaming (at most one centered label text is allowed).
|
||||
// Also skip if the parent node doesn't exist on canvas (was itself blocked).
|
||||
if (resolvedParent !== null && !parentNode) {
|
||||
return
|
||||
}
|
||||
|
|
@ -948,6 +949,58 @@ function applyTreeFixesRecursive(
|
|||
}
|
||||
}
|
||||
|
||||
// --- Fix 8.5: Keep screenshot placeholder label inside phone center ---
|
||||
{
|
||||
const phoneFrames = children.filter(
|
||||
(c: PenNode): c is FrameNode => c.type === 'frame' && (isPhonePlaceholderFrame(c) || isPhoneShaped(c)),
|
||||
)
|
||||
if (phoneFrames.length > 0) {
|
||||
const labelSiblings = children.filter((c: PenNode) => isPhonePlaceholderLabelNode(c))
|
||||
if (labelSiblings.length > 0) {
|
||||
const labelByPhone = new Map<string, PenNode>()
|
||||
for (const label of labelSiblings) {
|
||||
const phone = pickClosestPhonePlaceholderFrame(label, phoneFrames)
|
||||
if (!phone) continue
|
||||
if (!labelByPhone.has(phone.id)) {
|
||||
labelByPhone.set(phone.id, label)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [phoneId, labelNode] of labelByPhone.entries()) {
|
||||
const refreshedPhone = getNodeById(phoneId)
|
||||
if (!refreshedPhone || refreshedPhone.type !== 'frame') continue
|
||||
const content = getTextContentForNode(labelNode).trim()
|
||||
if (!content) continue
|
||||
const existingText = Array.isArray(refreshedPhone.children)
|
||||
? refreshedPhone.children.find((child) => child.type === 'text')
|
||||
: undefined
|
||||
const labelId = existingText?.id
|
||||
?? buildPhonePlaceholderLabelId(phoneId, getNodeById)
|
||||
const centeredLabel = createCenteredPhonePlaceholderLabelNode(
|
||||
refreshedPhone,
|
||||
content,
|
||||
existingText ?? labelNode,
|
||||
labelId,
|
||||
)
|
||||
updateNode(phoneId, {
|
||||
layout: 'none',
|
||||
children: [centeredLabel],
|
||||
} as Partial<PenNode>)
|
||||
}
|
||||
|
||||
for (const label of labelSiblings) {
|
||||
removeNode(label.id)
|
||||
}
|
||||
|
||||
const refreshed = getNodeById(node.id)
|
||||
if (refreshed && refreshed.type === 'frame' && Array.isArray(refreshed.children) && refreshed.children.length > 0) {
|
||||
node = refreshed
|
||||
children = refreshed.children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fix 6: Section padding for wide fill_container frames ---
|
||||
if (isLikelyWideSectionFrame(node) && node.layout && node.layout !== 'none') {
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
|
|
@ -2018,6 +2071,126 @@ function isPhonePlaceholderFrame(node: PenNode): boolean {
|
|||
return h / Math.max(1, w) > 1.2 && h >= 260
|
||||
}
|
||||
|
||||
function isPhonePlaceholderLabelContent(text: string): boolean {
|
||||
const normalized = text.trim().toLowerCase()
|
||||
if (!normalized) return false
|
||||
return /(app\s*截图|截图(占位|预览)|应用截图|屏幕预览|screenshot\s*(placeholder|preview)|screen\s*(placeholder|preview)|app\s*(screen|screenshot)\s*(placeholder|preview)?|mockup\s*(placeholder|preview)?)/.test(normalized)
|
||||
}
|
||||
|
||||
function isPhonePlaceholderLabelNode(node: PenNode): boolean {
|
||||
if (node.type !== 'text') return false
|
||||
return isPhonePlaceholderLabelContent(getTextContentForNode(node))
|
||||
}
|
||||
|
||||
function resolveNodeCenter(node: PenNode): { x: number; y: number } {
|
||||
const x = typeof node.x === 'number' ? node.x : 0
|
||||
const y = typeof node.y === 'number' ? node.y : 0
|
||||
const w = toSizeNumber('width' in node ? node.width : undefined, 0)
|
||||
const h = toSizeNumber('height' in node ? node.height : undefined, 0)
|
||||
return { x: x + w / 2, y: y + h / 2 }
|
||||
}
|
||||
|
||||
function pickClosestPhonePlaceholderFrame(
|
||||
node: PenNode,
|
||||
phoneFrames: FrameNode[],
|
||||
): FrameNode | null {
|
||||
if (phoneFrames.length === 0) return null
|
||||
if (phoneFrames.length === 1) return phoneFrames[0]
|
||||
const center = resolveNodeCenter(node)
|
||||
let best: FrameNode | null = null
|
||||
let bestScore = Number.POSITIVE_INFINITY
|
||||
for (const phone of phoneFrames) {
|
||||
const pc = resolveNodeCenter(phone)
|
||||
const score = Math.abs(center.x - pc.x) + Math.abs(center.y - pc.y)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
best = phone
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function buildPhonePlaceholderLabelId(
|
||||
phoneId: string,
|
||||
getNodeById: (id: string) => PenNode | undefined,
|
||||
): string {
|
||||
const base = `${phoneId}-placeholder-label`
|
||||
if (!getNodeById(base)) return base
|
||||
let idx = 2
|
||||
while (idx < 1000) {
|
||||
const candidate = `${base}-${idx}`
|
||||
if (!getNodeById(candidate)) return candidate
|
||||
idx += 1
|
||||
}
|
||||
return `${base}-${Date.now()}`
|
||||
}
|
||||
|
||||
function createCenteredPhonePlaceholderLabelNode(
|
||||
phone: FrameNode,
|
||||
content: string,
|
||||
source?: PenNode,
|
||||
forcedId?: string,
|
||||
): PenNode {
|
||||
const text = content.trim()
|
||||
const phoneW = toSizeNumber(phone.width, 260)
|
||||
const phoneH = toSizeNumber(phone.height, 520)
|
||||
const sourceText = source?.type === 'text' ? source : undefined
|
||||
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(text)
|
||||
const sourceFs = sourceText?.fontSize
|
||||
const fontSize = clamp(
|
||||
Math.round(typeof sourceFs === 'number' ? sourceFs : Math.max(18, phoneW * 0.075)),
|
||||
16,
|
||||
36,
|
||||
)
|
||||
const lineHeight = sourceText?.lineHeight
|
||||
? clamp(sourceText.lineHeight, 1.2, 1.6)
|
||||
: (hasCjk ? 1.32 : 1.26)
|
||||
const width = Math.max(120, Math.round(Math.min(phoneW - 40, Math.max(160, phoneW * 0.76))))
|
||||
const height = Math.max(
|
||||
Math.round(fontSize * lineHeight),
|
||||
estimateAutoHeight(text, fontSize, lineHeight, width),
|
||||
)
|
||||
const x = Math.round((phoneW - width) / 2)
|
||||
const y = Math.round((phoneH - height) / 2)
|
||||
const colors = getPlaceholderColors(phone.fill)
|
||||
|
||||
return {
|
||||
id: forcedId ?? `${phone.id}-placeholder-label`,
|
||||
type: 'text',
|
||||
name: 'Phone Placeholder Label',
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
content: text,
|
||||
fontFamily: sourceText?.fontFamily ?? (hasCjk ? 'Noto Sans SC' : 'Inter'),
|
||||
fontSize,
|
||||
fontWeight: sourceText?.fontWeight ?? 500,
|
||||
lineHeight,
|
||||
textAlign: 'center',
|
||||
textAlignVertical: 'middle',
|
||||
textGrowth: 'fixed-width',
|
||||
fill: sourceText?.fill ?? [{ type: 'solid', color: colors.textColor }],
|
||||
}
|
||||
}
|
||||
|
||||
function getNormalizedPhonePlaceholderChildren(node: FrameNode): PenNode[] | null {
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return null
|
||||
const textChildren = node.children.filter((child) => child.type === 'text')
|
||||
if (textChildren.length === 0) return []
|
||||
const primary = textChildren.find((child) => isPhonePlaceholderLabelNode(child)) ?? textChildren[0]
|
||||
const content = getTextContentForNode(primary).trim()
|
||||
if (!content) return []
|
||||
return [
|
||||
createCenteredPhonePlaceholderLabelNode(
|
||||
node,
|
||||
content,
|
||||
primary,
|
||||
primary.id,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function resolvePhonePlaceholderSize(node: PenNode): { width: number; height: number } | null {
|
||||
if (node.type !== 'frame') return null
|
||||
if (!isPhonePlaceholderFrame(node) && !isPhoneShaped(node)) return null
|
||||
|
|
@ -2066,8 +2239,9 @@ function getPhonePlaceholderNormalizationUpdates(node: PenNode): Record<string,
|
|||
updates.height = normalizedSize.height
|
||||
}
|
||||
|
||||
if (Array.isArray(node.children) && node.children.length > 0) {
|
||||
updates.children = []
|
||||
const normalizedChildren = getNormalizedPhonePlaceholderChildren(node)
|
||||
if (normalizedChildren) {
|
||||
updates.children = normalizedChildren
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? updates : null
|
||||
|
|
@ -2135,8 +2309,13 @@ function applyScreenshotFramePlaceholderHeuristic(node: PenNode): void {
|
|||
}
|
||||
node.effects = [{ type: 'shadow', offsetX: 0, offsetY: 4, blur: 24, spread: 0, color: 'rgba(0,0,0,0.12)' }]
|
||||
|
||||
// Remove all children — clean phone shape only, no text labels
|
||||
setNodeChildren(node, [])
|
||||
// Keep at most one centered label text node inside phone placeholders.
|
||||
const normalizedChildren = getNormalizedPhonePlaceholderChildren(node)
|
||||
if (normalizedChildren) {
|
||||
setNodeChildren(node, normalizedChildren)
|
||||
} else {
|
||||
setNodeChildren(node, [])
|
||||
}
|
||||
}
|
||||
|
||||
function isExplicitPlaceholderMarker(text: string): boolean {
|
||||
|
|
|
|||
Loading…
Reference in a new issue