mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
feat(ai): add table detection, badge/pill/button classification, and compact control heuristics
- Add isTableLikeFrame/isTableRowLikeFrame detection to skip text-fill and form-fill heuristics for table structures - Propagate table context through applyGenerationHeuristics and applyTreeFixesRecursive - Consolidate button marker regex (isExplicitButtonMarker, isCtaLikeLabel, isLikelyActionFrame) - Add inline info pill detection (isInlineInfoPillFrame/isInlineInfoPillLikeFrame) and sizing heuristic - Tighten badge char limits (CJK <=8 / Latin <=16), add clause separator rejection - Expand isCompactControlFrame to cover inline pills and generic compact controls - Add Fix 13.5 (inline info pill post-tree sizing) and Fix 13.6 (compact control width clamping) - Add Fix 11.5: expand fit_content columns with long text in horizontal content rows - Guard button spacing, button width, and form-child-fill heuristics against non-action frames
This commit is contained in:
parent
3996bd8c6d
commit
cd67b62fa1
1 changed files with 446 additions and 35 deletions
|
|
@ -766,7 +766,8 @@ export function animateNodesToCanvas(nodes: PenNode[]): void {
|
|||
useHistoryStore.getState().endBatch(useDocumentStore.getState().document)
|
||||
}
|
||||
|
||||
function applyGenerationHeuristics(node: PenNode): void {
|
||||
function applyGenerationHeuristics(node: PenNode, inTableContext = false): void {
|
||||
const tableContext = inTableContext || isTableLikeFrame(node)
|
||||
applyIconPathResolution(node)
|
||||
applyNoEmojiIconHeuristic(node)
|
||||
applyImagePlaceholderHeuristic(node)
|
||||
|
|
@ -777,6 +778,7 @@ function applyGenerationHeuristics(node: PenNode): void {
|
|||
applyHorizontalAlignCenterHeuristic(node)
|
||||
applyIconButtonSizing(node)
|
||||
applyBadgeSizing(node)
|
||||
applyInlineInfoPillSizing(node)
|
||||
applyButtonSpacingHeuristic(node)
|
||||
applyButtonWidthHeuristic(node)
|
||||
applyTextWrappingHeuristic(node)
|
||||
|
|
@ -791,15 +793,19 @@ function applyGenerationHeuristics(node: PenNode): void {
|
|||
// Ensure section-level frames have minimum horizontal padding
|
||||
applySectionPaddingHeuristic(node)
|
||||
// Tree-aware: fix text widths relative to parent layout
|
||||
applyTextFillContainerInLayout(node)
|
||||
if (!tableContext) {
|
||||
applyTextFillContainerInLayout(node)
|
||||
}
|
||||
// Card row equalization: horizontal rows of cards → fill_container
|
||||
applyCardRowEqualization(node)
|
||||
// Dense card rows (>=5 cards): compact internal content for layout stability
|
||||
applyDenseCardRowCompaction(node)
|
||||
// Form/card children: convert fixed-width buttons/inputs to fill_container
|
||||
applyFormChildFillContainer(node)
|
||||
if (!tableContext) {
|
||||
applyFormChildFillContainer(node)
|
||||
}
|
||||
for (const child of node.children) {
|
||||
applyGenerationHeuristics(child)
|
||||
applyGenerationHeuristics(child, tableContext)
|
||||
}
|
||||
// Remove decorative glow/shadow frames next to phone placeholders
|
||||
applyRemoveDecorativeGlowSiblings(node)
|
||||
|
|
@ -832,6 +838,7 @@ function applyTreeFixesRecursive(
|
|||
getNodeById: (id: string) => PenNode | undefined,
|
||||
updateNode: (id: string, updates: Partial<PenNode>) => void,
|
||||
removeNode: (id: string) => void,
|
||||
inheritedTableContext = false,
|
||||
): void {
|
||||
if (node.type !== 'frame') return
|
||||
|
||||
|
|
@ -856,6 +863,7 @@ function applyTreeFixesRecursive(
|
|||
// Keep a mutable reference to the current children list.
|
||||
// This gets refreshed after removals so subsequent fixes see the up-to-date tree.
|
||||
let children: PenNode[] = node.children
|
||||
const tableLikeNode = inheritedTableContext || isTableLikeFrame(node)
|
||||
|
||||
// --- Fix 14: Flatten redundant single "Inner" wrappers ---
|
||||
{
|
||||
|
|
@ -1026,7 +1034,7 @@ function applyTreeFixesRecursive(
|
|||
|
||||
// --- Fix 1: Text in layout frames → Fill Width + Auto Height ---
|
||||
// Skip if parent is fit_content (hug) — fill_container child breaks hug parent layout
|
||||
if (node.layout && node.layout !== 'none' && !isBadgeLikeFrame(node)) {
|
||||
if (node.layout && node.layout !== 'none' && !isBadgeLikeFrame(node) && !tableLikeNode) {
|
||||
// Compute parent content width for accurate text height estimation
|
||||
const nodeW = toSizeNumber(node.width, 0)
|
||||
let nodePad = parsePaddingValues('padding' in node ? node.padding : undefined)
|
||||
|
|
@ -1277,6 +1285,57 @@ function applyTreeFixesRecursive(
|
|||
}
|
||||
}
|
||||
|
||||
// --- Fix 13.5: Inline info pill sizing (post-tree safety pass) ---
|
||||
{
|
||||
if (isInlineInfoPillLikeFrame(node)) {
|
||||
const frameUpdates13b: Record<string, unknown> = {}
|
||||
if (typeof node.width === 'string' && node.width.startsWith('fill_container')) {
|
||||
frameUpdates13b.width = 'fit_content'
|
||||
} else if (typeof node.width === 'number') {
|
||||
const nodeW13b = toSizeNumber(node.width, 0)
|
||||
const maxW13b = generationCanvasWidth <= 520 ? Math.round(generationCanvasWidth * 0.9) : 420
|
||||
if (nodeW13b > maxW13b) frameUpdates13b.width = 'fit_content'
|
||||
}
|
||||
if (!node.layout || node.layout === 'none') frameUpdates13b.layout = 'horizontal'
|
||||
if (!node.alignItems) frameUpdates13b.alignItems = 'center'
|
||||
if (!node.justifyContent || node.justifyContent === 'center') frameUpdates13b.justifyContent = 'start'
|
||||
const gap13b = toGapNumber('gap' in node ? node.gap : undefined)
|
||||
if (gap13b < 6) frameUpdates13b.gap = 8
|
||||
if (Object.keys(frameUpdates13b).length > 0) {
|
||||
updateNode(node.id, frameUpdates13b as Partial<PenNode>)
|
||||
const refreshed13b = getNodeById(node.id)
|
||||
if (refreshed13b && refreshed13b.type === 'frame' && Array.isArray(refreshed13b.children)) {
|
||||
node = refreshed13b
|
||||
children = refreshed13b.children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fix 13.6: Compact controls should not be auto-stretched to full width ---
|
||||
{
|
||||
if (isCompactControlFrame(node)) {
|
||||
const frameUpdates13c: Record<string, unknown> = {}
|
||||
if (typeof node.width === 'string' && node.width.startsWith('fill_container')) {
|
||||
frameUpdates13c.width = 'fit_content'
|
||||
} else if (typeof node.width === 'number') {
|
||||
const controlMinW = estimateCompactControlMinWidth(node)
|
||||
const controlW = toSizeNumber(node.width, 0)
|
||||
if (controlMinW > 0 && controlW > Math.max(240, controlMinW * 1.9)) {
|
||||
frameUpdates13c.width = 'fit_content'
|
||||
}
|
||||
}
|
||||
if (Object.keys(frameUpdates13c).length > 0) {
|
||||
updateNode(node.id, frameUpdates13c as Partial<PenNode>)
|
||||
const refreshed13c = getNodeById(node.id)
|
||||
if (refreshed13c && refreshed13c.type === 'frame' && Array.isArray(refreshed13c.children)) {
|
||||
node = refreshed13c
|
||||
children = refreshed13c.children
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fix 9: Button spacing (padding/gap/height) ---
|
||||
{
|
||||
const nodeH = toSizeNumber(node.height, 0)
|
||||
|
|
@ -1285,7 +1344,15 @@ function applyTreeFixesRecursive(
|
|||
const hasTextChild = children.some(
|
||||
(c: PenNode) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
|
||||
)
|
||||
if (nodeH > 0 && nodeH <= 72 && (isHorizontal || isCentered) && hasTextChild && !isBadgeLikeFrame(node)) {
|
||||
if (
|
||||
nodeH > 0
|
||||
&& nodeH <= 72
|
||||
&& (isHorizontal || isCentered)
|
||||
&& hasTextChild
|
||||
&& !isBadgeLikeFrame(node)
|
||||
&& !isInlineInfoPillLikeFrame(node)
|
||||
&& (isCompactButtonLikeFrame(node) || isLikelyActionFrame(node))
|
||||
) {
|
||||
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
|
||||
const minV = 8
|
||||
const minH2 = 16
|
||||
|
|
@ -1324,7 +1391,7 @@ function applyTreeFixesRecursive(
|
|||
|
||||
// --- Fix 11: Form children alignment & button row layout ---
|
||||
// Skip if parent is fit_content (hug) — fill_container children break hug layout
|
||||
if (node.layout === 'vertical' && node.width !== 'fit_content' && children.length >= 2) {
|
||||
if (node.layout === 'vertical' && node.width !== 'fit_content' && children.length >= 2 && !tableLikeNode) {
|
||||
const parentW11 = toSizeNumber(node.width, 0)
|
||||
const pad11 = parsePaddingValues('padding' in node ? node.padding : undefined)
|
||||
const contentW11 = parentW11 > 0 ? parentW11 - pad11.left - pad11.right : 0
|
||||
|
|
@ -1353,8 +1420,7 @@ function applyTreeFixesRecursive(
|
|||
// it creates narrow columns even when parent has enough room.
|
||||
if (
|
||||
childWidthIsFit
|
||||
&& !isBadgeLikeFrame(child)
|
||||
&& !isCompactButtonLikeFrame(child)
|
||||
&& !isCompactControlFrame(child)
|
||||
&& frameNeedsFillWidthForTextContent(child)
|
||||
) {
|
||||
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
|
||||
|
|
@ -1363,7 +1429,11 @@ function applyTreeFixesRecursive(
|
|||
|
||||
// Overflow: child wider than parent content area
|
||||
if (contentW11 > 0 && childW > contentW11) {
|
||||
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
|
||||
if (isCompactControlFrame(child)) {
|
||||
updateNode(child.id, { width: 'fit_content' } as Partial<PenNode>)
|
||||
} else {
|
||||
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -1374,16 +1444,21 @@ function applyTreeFixesRecursive(
|
|||
&& childWidthIsNumber
|
||||
&& childW > 0
|
||||
&& childW < contentW11 * 0.72
|
||||
&& !isBadgeLikeFrame(child)
|
||||
&& !isCompactButtonLikeFrame(child)
|
||||
&& !isCompactControlFrame(child)
|
||||
&& frameNeedsFillWidthForTextContent(child)
|
||||
) {
|
||||
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
|
||||
continue
|
||||
}
|
||||
|
||||
// Consistency: if a sibling uses fill_container, match it
|
||||
if (hasFillSibling11 && childH > 0 && childH <= 72 && !isCompactControlFrame(child)) {
|
||||
// Consistency: if a sibling uses fill_container, only expand likely action controls.
|
||||
if (
|
||||
hasFillSibling11
|
||||
&& childH > 0
|
||||
&& childH <= 72
|
||||
&& !isCompactControlFrame(child)
|
||||
&& isLikelyActionFrame(child)
|
||||
) {
|
||||
const hasContent = 'children' in child && Array.isArray(child.children)
|
||||
&& child.children.some((gc: PenNode) => gc.type === 'text' || gc.type === 'path')
|
||||
if (hasContent) {
|
||||
|
|
@ -1413,9 +1488,21 @@ function applyTreeFixesRecursive(
|
|||
}
|
||||
}
|
||||
|
||||
// --- Fix 11.5: Horizontal content rows — long-text fit_content columns should fill ---
|
||||
if (node.layout === 'horizontal' && !tableLikeNode && children.length >= 2) {
|
||||
for (const child of children) {
|
||||
if (child.type !== 'frame') continue
|
||||
if (typeof child.width !== 'string' || !child.width.startsWith('fit_content')) continue
|
||||
if (isPhonePlaceholderFrame(child) || isDividerLikeFrame(child)) continue
|
||||
if (isCompactControlFrame(child)) continue
|
||||
if (!frameNeedsFillWidthForTextContent(child)) continue
|
||||
updateNode(child.id, { width: 'fill_container' } as Partial<PenNode>)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into child frames
|
||||
for (const child of children) {
|
||||
applyTreeFixesRecursive(child, getNodeById, updateNode, removeNode)
|
||||
applyTreeFixesRecursive(child, getNodeById, updateNode, removeNode, tableLikeNode)
|
||||
}
|
||||
|
||||
// --- Fix 10: Horizontal overflow — reduce gap, then expand parent ---
|
||||
|
|
@ -2444,17 +2531,186 @@ function getSingleInnerWrapperFlattenUpdates(
|
|||
return updates
|
||||
}
|
||||
|
||||
function isTableMarker(text: string): boolean {
|
||||
return /(^|[\s_-])(table|datatable|gridtable|tabular|thead|tbody|tableheader|tablebody|table-row)([\s_-]|$)|表格|数据表|表头|表体|级别表|词汇量表|对应考试/.test(text)
|
||||
}
|
||||
|
||||
function isTableRowLikeFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return false
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
const explicitRowMarker = /(^|[\s_-])(row|table-row|tr)([\s_-]|$)|表行/.test(marker)
|
||||
const horizontalRowLike = node.layout === 'horizontal'
|
||||
if (!explicitRowMarker && !horizontalRowLike) return false
|
||||
if (/(button|btn|cta|badge|chip|pill|tag|nav|menu|hero|card|panel|按钮|标签|导航|卡片)/.test(marker)) return false
|
||||
const textCount = node.children.filter(
|
||||
(child) => child.type === 'text' && getTextContentForNode(child).trim().length > 0,
|
||||
).length
|
||||
const frameCount = node.children.filter((child) => child.type === 'frame').length
|
||||
return textCount + frameCount >= 2
|
||||
}
|
||||
|
||||
function isTableLikeFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (isTableMarker(marker)) return true
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return false
|
||||
const rowLikeChildren = node.children.filter((child): child is FrameNode => isTableRowLikeFrame(child))
|
||||
if (rowLikeChildren.length < 3) return false
|
||||
const colCounts = rowLikeChildren
|
||||
.map((row) => row.children?.length ?? 0)
|
||||
.filter((count) => count >= 2)
|
||||
if (colCounts.length < 3) return false
|
||||
const maxCols = Math.max(...colCounts)
|
||||
const minCols = Math.min(...colCounts)
|
||||
return maxCols >= 2 && maxCols - minCols <= 1
|
||||
}
|
||||
|
||||
const BUTTON_MARKER_REGEX = /(^|[\s_-])(button|btn|cta|submit|download|install|signup|sign[-_\s]*in|login|register)([\s_-]|$)|按钮|下载|立即|开始|购买|了解|进入|登录|注册|提交|继续|安装|试用|app\s*store|google\s*play/
|
||||
const CTA_LABEL_REGEX = /(download|get\s*it\s*on|learn\s*more|start|continue|open|install|try|buy|sign\s*in|log\s*in|register|submit|app\s*store|google\s*play|免费下载|立即下载|下载|了解|查看|开始|继续|进入|安装|试用|购买|登录|注册|提交|前往|打开|立即体验|了解更多|马上体验)/
|
||||
|
||||
function isExplicitButtonMarker(marker: string): boolean {
|
||||
return BUTTON_MARKER_REGEX.test(marker)
|
||||
}
|
||||
|
||||
function isCtaLikeLabel(text: string): boolean {
|
||||
return CTA_LABEL_REGEX.test(text.toLowerCase().trim())
|
||||
}
|
||||
|
||||
function isLikelyActionFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return false
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (isExplicitButtonMarker(marker)) return true
|
||||
return node.children.some((child) =>
|
||||
child.type === 'text'
|
||||
&& typeof child.content === 'string'
|
||||
&& isCtaLikeLabel(child.content),
|
||||
)
|
||||
}
|
||||
|
||||
function isInlineInfoPillFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 5) return false
|
||||
if (isTableLikeFrame(node)) return false
|
||||
if (isBadgeLikeFrame(node) || isCompactButtonLikeFrame(node)) return false
|
||||
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (/(button|btn|cta|submit|download|install|card|panel|table|表格|按钮|下载|卡片)/.test(marker)) return false
|
||||
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
if (h > 0 && h > 56) return false
|
||||
|
||||
const textChildren = node.children.filter(
|
||||
(child) => child.type === 'text' && typeof child.content === 'string' && child.content.trim().length > 0,
|
||||
)
|
||||
if (textChildren.length !== 1) return false
|
||||
const text = getTextContentForNode(textChildren[0]).trim()
|
||||
if (!text) return false
|
||||
if (/[\n\r]/.test(text)) return false
|
||||
const hasClauseSeparator = /[,,。;;::]/.test(text)
|
||||
if (hasClauseSeparator) return false
|
||||
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
|
||||
const compactCharCount = text.replace(/\s+/g, '').length
|
||||
const maxInlineChars = hasCjk ? 14 : 28
|
||||
if (compactCharCount > maxInlineChars) return false
|
||||
|
||||
const hasNestedFrame = node.children.some((child) => child.type === 'frame')
|
||||
if (hasNestedFrame) return false
|
||||
|
||||
const iconChildren = node.children.filter((child) =>
|
||||
child.type === 'path' || child.type === 'ellipse' || child.type === 'rectangle',
|
||||
)
|
||||
if (iconChildren.length > 2) return false
|
||||
if (iconChildren.some((child) => !isInlinePillIconCandidate(child))) return false
|
||||
|
||||
return node.layout === 'horizontal'
|
||||
|| node.alignItems === 'center'
|
||||
|| node.justifyContent === 'center'
|
||||
}
|
||||
|
||||
function isInlinePillIconCandidate(node: PenNode): boolean {
|
||||
if (!(node.type === 'path' || node.type === 'ellipse' || node.type === 'rectangle')) return false
|
||||
const w = toSizeNumber('width' in node ? node.width : undefined, 0)
|
||||
const h = toSizeNumber('height' in node ? node.height : undefined, 0)
|
||||
if (w <= 0 || h <= 0) {
|
||||
// Path nodes can miss explicit box sizing before path normalization.
|
||||
return node.type === 'path'
|
||||
}
|
||||
return Math.max(w, h) <= 20
|
||||
}
|
||||
|
||||
function isInlineInfoPillLikeFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
if (isInlineInfoPillFrame(node)) return true
|
||||
if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 6) return false
|
||||
if (isTableLikeFrame(node)) return false
|
||||
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (/(button|btn|cta|submit|download|install|card|panel|table|表格|按钮|下载|卡片)/.test(marker)) return false
|
||||
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
if (h > 0 && h > 60) return false
|
||||
const centeredLike = node.layout === 'horizontal' || node.alignItems === 'center' || node.justifyContent === 'center'
|
||||
if (!centeredLike) return false
|
||||
|
||||
const textChildren = node.children.filter(
|
||||
(child) => child.type === 'text' && typeof child.content === 'string' && child.content.trim().length > 0,
|
||||
)
|
||||
if (textChildren.length !== 1) return false
|
||||
const text = getTextContentForNode(textChildren[0]).trim()
|
||||
if (!text) return false
|
||||
if (/[\n\r]/.test(text) || /[,,。;;::]/.test(text)) return false
|
||||
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
|
||||
const compactLen = text.replace(/\s+/g, '').length
|
||||
if (compactLen > (hasCjk ? 20 : 36)) return false
|
||||
|
||||
const frameChildren = node.children.filter((child) => child.type === 'frame')
|
||||
if (frameChildren.length > 1) return false
|
||||
for (const frameChild of frameChildren) {
|
||||
if (!Array.isArray(frameChild.children)) return false
|
||||
if (frameChild.children.some((child) => child.type === 'text' && getTextContentForNode(child).trim().length > 0)) return false
|
||||
const fw = toSizeNumber('width' in frameChild ? frameChild.width : undefined, 0)
|
||||
const fh = toSizeNumber('height' in frameChild ? frameChild.height : undefined, 0)
|
||||
if ((fw > 0 && fw > 24) || (fh > 0 && fh > 24)) return false
|
||||
const onlyIconChildren = frameChild.children.every(
|
||||
(child) => child.type === 'path' || child.type === 'ellipse' || child.type === 'rectangle' || child.type === 'image',
|
||||
)
|
||||
if (!onlyIconChildren) return false
|
||||
}
|
||||
|
||||
const iconChildren = node.children.filter((child) =>
|
||||
child.type === 'path' || child.type === 'ellipse' || child.type === 'rectangle' || child.type === 'image',
|
||||
)
|
||||
let iconCount = iconChildren.length
|
||||
for (const frameChild of frameChildren) {
|
||||
iconCount += frameChild.children?.length ?? 0
|
||||
}
|
||||
if (iconCount > 2) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function isTinyDotLikeIconNode(node: PenNode): boolean {
|
||||
if (!(node.type === 'path' || node.type === 'ellipse' || node.type === 'rectangle')) return false
|
||||
const w = toSizeNumber('width' in node ? node.width : undefined, 0)
|
||||
const h = toSizeNumber('height' in node ? node.height : undefined, 0)
|
||||
if (w <= 0 || h <= 0) return false
|
||||
return Math.max(w, h) <= 12
|
||||
}
|
||||
|
||||
function isBadgeLikeFrame(node: PenNode): boolean {
|
||||
if (node.type !== 'frame') return false
|
||||
if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 3) return false
|
||||
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (isTableMarker(marker)) return false
|
||||
const explicitBadgeMarker = /(badge|tag|chip|pill|label|徽章|标签|状态|模式)/.test(marker)
|
||||
const explicitButtonMarker = /(^|[\s_-])(button|btn|cta|submit|download|install|signup|sign[-_\s]*in|login|register)([\s_-]|$)|按钮|下载|立即|开始|购买|了解|进入|登录|注册|提交|继续|安装|试用/.test(marker)
|
||||
const explicitButtonMarker = isExplicitButtonMarker(marker)
|
||||
if (explicitButtonMarker && !explicitBadgeMarker) return false
|
||||
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
if (h > 0 && h > 40) return false
|
||||
if (h > 0 && h > 56) return false
|
||||
|
||||
const textChildren = node.children.filter(
|
||||
(c) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
|
||||
|
|
@ -2463,14 +2719,16 @@ function isBadgeLikeFrame(node: PenNode): boolean {
|
|||
const textNode = textChildren[0]
|
||||
if (textNode.type !== 'text' || typeof textNode.content !== 'string') return false
|
||||
const label = textNode.content.trim()
|
||||
const lowerLabel = label.toLowerCase()
|
||||
const ctaLikeLabel = /(download|get\s*it\s*on|learn\s*more|start|continue|open|install|try|buy|sign\s*in|log\s*in|register|submit|app\s*store|google\s*play|免费下载|立即下载|下载|了解|查看|开始|继续|进入|安装|试用|购买|登录|注册|提交|前往|打开)/.test(lowerLabel)
|
||||
if (!label) return false
|
||||
const hasClauseSeparator = /[,,。;;::]|(?:\s[—-]\s)/.test(label)
|
||||
if (hasClauseSeparator && !explicitBadgeMarker) return false
|
||||
const ctaLikeLabel = isCtaLikeLabel(label)
|
||||
if (ctaLikeLabel && !explicitBadgeMarker) return false
|
||||
const charCount = [...label].length
|
||||
const compactCharCount = label.replace(/\s+/g, '').length
|
||||
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(label)
|
||||
const fontSize = textNode.fontSize ?? 14
|
||||
const maxLabelChars = hasCjk ? 10 : 18
|
||||
if (charCount > maxLabelChars || fontSize > 16) return false
|
||||
const maxLabelChars = hasCjk ? 8 : 16
|
||||
if (compactCharCount > maxLabelChars || fontSize > 16) return false
|
||||
|
||||
const allowedChildren = node.children.every((c) =>
|
||||
c.type === 'text' || c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle',
|
||||
|
|
@ -2490,7 +2748,7 @@ function isBadgeLikeFrame(node: PenNode): boolean {
|
|||
if (hasLargeIcon && !explicitBadgeMarker) return false
|
||||
|
||||
const w = toSizeNumber(node.width, 0)
|
||||
if (w > 0 && w > 260) return false
|
||||
if (w > 0 && w > 300 && !explicitBadgeMarker) return false
|
||||
const looksButtonByWidth = w >= 140 && (
|
||||
node.layout === 'horizontal' || node.alignItems === 'center' || node.justifyContent === 'center'
|
||||
)
|
||||
|
|
@ -2498,7 +2756,7 @@ function isBadgeLikeFrame(node: PenNode): boolean {
|
|||
|
||||
const pad = parsePaddingValues('padding' in node ? node.padding : undefined)
|
||||
const inferredH = h > 0 ? h : Math.round(fontSize * 1.2 + pad.top + pad.bottom)
|
||||
if (inferredH > 40) return false
|
||||
if (inferredH > 56) return false
|
||||
|
||||
return node.layout === 'horizontal'
|
||||
|| node.alignItems === 'center'
|
||||
|
|
@ -2521,6 +2779,10 @@ function isCompactButtonLikeFrame(node: PenNode): boolean {
|
|||
(c) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0,
|
||||
)
|
||||
if (textChildren.length === 0 || textChildren.length > 2) return false
|
||||
const ctaLikeByLabel = textChildren.some((textNode) =>
|
||||
textNode.type === 'text'
|
||||
&& isCtaLikeLabel(getTextContentForNode(textNode)),
|
||||
)
|
||||
for (const textNode of textChildren) {
|
||||
if (textNode.type !== 'text') return false
|
||||
const content = getTextContentForNode(textNode).trim()
|
||||
|
|
@ -2540,15 +2802,112 @@ function isCompactButtonLikeFrame(node: PenNode): boolean {
|
|||
|| node.justifyContent === 'center'
|
||||
if (!centeredLike) return false
|
||||
|
||||
const hasIcon = node.children.some((c) =>
|
||||
const iconChildren = node.children.filter((c) =>
|
||||
c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle' || c.type === 'image',
|
||||
)
|
||||
const explicitButtonMarker = /(^|[\s_-])(button|btn|cta|submit|download|install|signup|sign[-_\s]*in|login|register)([\s_-]|$)|按钮|下载|立即|开始|购买|了解|进入|登录|注册|提交|继续|安装|试用|app\s*store|google\s*play/.test(marker)
|
||||
return explicitButtonMarker || hasIcon || textChildren.length === 1
|
||||
const hasIcon = iconChildren.length > 0
|
||||
const explicitButtonMarker = isExplicitButtonMarker(marker)
|
||||
const hasOnlyTinyDotIcon = hasIcon
|
||||
&& iconChildren.every((iconNode) => isTinyDotLikeIconNode(iconNode))
|
||||
|
||||
if (!explicitButtonMarker && !ctaLikeByLabel) {
|
||||
if (textChildren.length === 1 && hasIcon) {
|
||||
const singleText = textChildren[0]
|
||||
const label = singleText.type === 'text' ? getTextContentForNode(singleText).trim() : ''
|
||||
if (label) {
|
||||
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(label)
|
||||
const compactLen = label.replace(/\s+/g, '').length
|
||||
const sentenceLike = /[,,。;;::]/.test(label)
|
||||
const maxIconSize = iconChildren.reduce((max, iconNode) => {
|
||||
const iw = toSizeNumber('width' in iconNode ? iconNode.width : undefined, 0)
|
||||
const ih = toSizeNumber('height' in iconNode ? iconNode.height : undefined, 0)
|
||||
return Math.max(max, iw, ih)
|
||||
}, 0)
|
||||
const nonActionInfoPillLike = !sentenceLike
|
||||
&& compactLen <= (hasCjk ? 20 : 36)
|
||||
&& iconChildren.length <= 2
|
||||
&& maxIconSize <= 20
|
||||
if (nonActionInfoPillLike) return false
|
||||
}
|
||||
}
|
||||
if (!hasIcon) return false
|
||||
if (hasOnlyTinyDotIcon && textChildren.length === 1) return false
|
||||
}
|
||||
|
||||
return explicitButtonMarker || ctaLikeByLabel || hasIcon
|
||||
}
|
||||
|
||||
function isCompactControlFrame(node: PenNode): boolean {
|
||||
return isBadgeLikeFrame(node) || isCompactButtonLikeFrame(node)
|
||||
if (isBadgeLikeFrame(node) || isCompactButtonLikeFrame(node) || isInlineInfoPillLikeFrame(node)) {
|
||||
return true
|
||||
}
|
||||
if (node.type !== 'frame') return false
|
||||
if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 6) return false
|
||||
if (isTableLikeFrame(node) || isPhonePlaceholderFrame(node) || isDividerLikeFrame(node)) return false
|
||||
|
||||
const marker = `${node.name ?? ''} ${node.id}`.toLowerCase()
|
||||
if (/(card|panel|section|container|feature|service|list|item|table|grid|row|卡片|面板|容器|表格|列表|行)/.test(marker)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
if (h <= 0 || h > 76) return false
|
||||
const centeredLike = node.layout === 'horizontal'
|
||||
|| node.alignItems === 'center'
|
||||
|| node.justifyContent === 'center'
|
||||
if (!centeredLike) return false
|
||||
|
||||
const textChildren = node.children.filter(
|
||||
(child): child is PenNode => child.type === 'text' && getTextContentForNode(child).trim().length > 0,
|
||||
)
|
||||
if (textChildren.length === 0 || textChildren.length > 2) return false
|
||||
|
||||
for (const textNode of textChildren) {
|
||||
if (textNode.type !== 'text') return false
|
||||
const text = getTextContentForNode(textNode).trim()
|
||||
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
|
||||
const compactLen = text.replace(/\s+/g, '').length
|
||||
if (!text || /[\n\r]/.test(text)) return false
|
||||
if (compactLen > (hasCjk ? 22 : 40)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function estimateCompactControlMinWidth(node: PenNode): number {
|
||||
if (node.type !== 'frame' || !Array.isArray(node.children) || node.children.length === 0) return 0
|
||||
const padding = parsePaddingValues('padding' in node ? node.padding : undefined)
|
||||
const gap = toGapNumber('gap' in node ? node.gap : undefined)
|
||||
|
||||
let contentWidth = 0
|
||||
let itemCount = 0
|
||||
for (const child of node.children) {
|
||||
if (child.type === 'text') {
|
||||
const text = getTextContentForNode(child).trim()
|
||||
if (!text) continue
|
||||
contentWidth += estimateSingleLineTextWidth(text, child.fontSize ?? 16)
|
||||
itemCount += 1
|
||||
continue
|
||||
}
|
||||
if (child.type === 'frame' && Array.isArray(child.children)) {
|
||||
const frameHasText = child.children.some((gc) => gc.type === 'text' && getTextContentForNode(gc).trim().length > 0)
|
||||
if (!frameHasText) {
|
||||
const fw = toSizeNumber('width' in child ? child.width : undefined, 0)
|
||||
const fh = toSizeNumber('height' in child ? child.height : undefined, 0)
|
||||
const fallback = Math.max(fw, fh, 14)
|
||||
contentWidth += fallback
|
||||
itemCount += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
const genericW = toSizeNumber('width' in child ? child.width : undefined, 0)
|
||||
if (genericW > 0) {
|
||||
contentWidth += genericW
|
||||
itemCount += 1
|
||||
}
|
||||
}
|
||||
if (itemCount > 1) contentWidth += gap * (itemCount - 1)
|
||||
return Math.max(0, Math.round(contentWidth + padding.left + padding.right + 20))
|
||||
}
|
||||
|
||||
function isLongTextLikeForLayout(node: PenNode): boolean {
|
||||
|
|
@ -2672,6 +3031,7 @@ function applyIconButtonSizing(node: PenNode): void {
|
|||
function applyBadgeSizing(node: PenNode): void {
|
||||
if (node.type !== 'frame') return
|
||||
if (!isBadgeLikeFrame(node)) return
|
||||
if (isTableLikeFrame(node)) return
|
||||
if (!Array.isArray(node.children)) return
|
||||
|
||||
if (typeof node.width === 'string' && node.width.startsWith('fill_container')) {
|
||||
|
|
@ -2728,6 +3088,9 @@ function applyBadgeSizing(node: PenNode): void {
|
|||
const frameW = toSizeNumber(node.width, 0)
|
||||
const newPad = parsePaddingValues(node.padding)
|
||||
const minFrameW = Math.round(estimatedW + newPad.left + newPad.right + 8)
|
||||
if (typeof node.width === 'number' && frameW > Math.max(220, minFrameW * 1.6)) {
|
||||
node.width = 'fit_content'
|
||||
}
|
||||
if (typeof node.width === 'number' && frameW > 0 && frameW < minFrameW) {
|
||||
node.width = minFrameW
|
||||
}
|
||||
|
|
@ -2781,6 +3144,39 @@ function applyBadgeSizing(node: PenNode): void {
|
|||
}
|
||||
}
|
||||
|
||||
function applyInlineInfoPillSizing(node: PenNode): void {
|
||||
if (node.type !== 'frame') return
|
||||
if (!isInlineInfoPillLikeFrame(node)) return
|
||||
if (!Array.isArray(node.children)) return
|
||||
|
||||
const w = toSizeNumber(node.width, 0)
|
||||
const maxW = generationCanvasWidth <= 520 ? Math.round(generationCanvasWidth * 0.9) : 420
|
||||
if (typeof node.width === 'string' && node.width.startsWith('fill_container')) {
|
||||
node.width = 'fit_content'
|
||||
} else if (typeof node.width === 'number' && w > maxW) {
|
||||
node.width = 'fit_content'
|
||||
}
|
||||
|
||||
if (!node.layout || node.layout === 'none') node.layout = 'horizontal'
|
||||
if (!node.alignItems) node.alignItems = 'center'
|
||||
if (!node.justifyContent) node.justifyContent = 'start'
|
||||
if (!node.gap || toGapNumber(node.gap) < 6) node.gap = 8
|
||||
|
||||
const textNode = node.children.find(
|
||||
(child): child is PenNode => child.type === 'text' && getTextContentForNode(child).trim().length > 0,
|
||||
)
|
||||
if (!textNode || textNode.type !== 'text') return
|
||||
const text = getTextContentForNode(textNode).trim()
|
||||
const hasCjk = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(text)
|
||||
if (!textNode.lineHeight || textNode.lineHeight > 1.4 || textNode.lineHeight < 1.1) {
|
||||
textNode.lineHeight = hasCjk ? 1.25 : 1.2
|
||||
}
|
||||
textNode.textAlignVertical = 'middle'
|
||||
if (!textNode.textGrowth || textNode.textGrowth === 'fixed-width') {
|
||||
textNode.textGrowth = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure button-like frames have minimum internal padding and gap.
|
||||
* AI often generates buttons with padding: 0 or tiny padding, causing cramped text.
|
||||
|
|
@ -2791,6 +3187,9 @@ function applyButtonSpacingHeuristic(node: PenNode): void {
|
|||
if (!Array.isArray(node.children) || node.children.length === 0) return
|
||||
// Badge chips use tighter spacing; button defaults make them look oversized.
|
||||
if (isBadgeLikeFrame(node)) return
|
||||
if (isInlineInfoPillLikeFrame(node)) return
|
||||
if (isTableLikeFrame(node)) return
|
||||
if (!isCompactButtonLikeFrame(node) && !isLikelyActionFrame(node)) return
|
||||
const h = toSizeNumber(node.height, 0)
|
||||
// Only target small frames that look like buttons/badges (height ≤ 72px)
|
||||
if (h <= 0 || h > 72) return
|
||||
|
|
@ -2846,6 +3245,9 @@ function applyButtonSpacingHeuristic(node: PenNode): void {
|
|||
function applyButtonWidthHeuristic(node: PenNode): void {
|
||||
if (node.type !== 'frame') return
|
||||
if (!Array.isArray(node.children) || node.children.length === 0) return
|
||||
if (isInlineInfoPillLikeFrame(node)) return
|
||||
if (isTableLikeFrame(node)) return
|
||||
if (!isCompactControlFrame(node) && !isLikelyActionFrame(node)) return
|
||||
// Only target fixed-width frames (buttons/badges/tags/feature cards)
|
||||
if (typeof node.width !== 'number') return
|
||||
const w = node.width
|
||||
|
|
@ -3036,6 +3438,7 @@ function estimateSingleLineTextWidth(text: string, fontSize: number): number {
|
|||
function applyFormChildFillContainer(node: PenNode): void {
|
||||
if (node.type !== 'frame') return
|
||||
if (node.layout !== 'vertical') return
|
||||
if (isTableLikeFrame(node)) return
|
||||
// Never convert children when parent is fit_content (hug) — breaks layout
|
||||
if (node.width === 'fit_content') return
|
||||
if (!Array.isArray(node.children) || node.children.length < 2) return
|
||||
|
|
@ -3069,8 +3472,7 @@ function applyFormChildFillContainer(node: PenNode): void {
|
|||
// Long-text wrappers should use fill_container, otherwise they collapse to content width.
|
||||
if (
|
||||
childWidthIsFit
|
||||
&& !isBadgeLikeFrame(child)
|
||||
&& !isCompactButtonLikeFrame(child)
|
||||
&& !isCompactControlFrame(child)
|
||||
&& frameNeedsFillWidthForTextContent(child)
|
||||
) {
|
||||
;(child as unknown as Record<string, unknown>).width = 'fill_container'
|
||||
|
|
@ -3079,7 +3481,11 @@ function applyFormChildFillContainer(node: PenNode): void {
|
|||
|
||||
// Overflow: child wider than parent content area
|
||||
if (contentW > 0 && childW > contentW) {
|
||||
;(child as unknown as Record<string, unknown>).width = 'fill_container'
|
||||
if (isCompactControlFrame(child)) {
|
||||
;(child as unknown as Record<string, unknown>).width = 'fit_content'
|
||||
} else {
|
||||
;(child as unknown as Record<string, unknown>).width = 'fill_container'
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -3090,8 +3496,7 @@ function applyFormChildFillContainer(node: PenNode): void {
|
|||
&& childWidthIsNumber
|
||||
&& childW > 0
|
||||
&& childW < contentW * 0.72
|
||||
&& !isBadgeLikeFrame(child)
|
||||
&& !isCompactButtonLikeFrame(child)
|
||||
&& !isCompactControlFrame(child)
|
||||
&& frameNeedsFillWidthForTextContent(child)
|
||||
) {
|
||||
;(child as unknown as Record<string, unknown>).width = 'fill_container'
|
||||
|
|
@ -3099,8 +3504,14 @@ function applyFormChildFillContainer(node: PenNode): void {
|
|||
}
|
||||
|
||||
// Consistency: if a sibling already uses fill_container,
|
||||
// convert input/button-like children (short height, has content) too
|
||||
if (hasFillSibling && childH > 0 && childH <= 72 && !isCompactControlFrame(child)) {
|
||||
// only convert likely action controls (avoid stretching badges/chips).
|
||||
if (
|
||||
hasFillSibling
|
||||
&& childH > 0
|
||||
&& childH <= 72
|
||||
&& !isCompactControlFrame(child)
|
||||
&& isLikelyActionFrame(child)
|
||||
) {
|
||||
const hasContent = 'children' in child && Array.isArray(child.children)
|
||||
&& child.children.some((gc) => gc.type === 'text' || gc.type === 'path')
|
||||
if (hasContent) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue