diff --git a/src/canvas/canvas-object-factory.ts b/src/canvas/canvas-object-factory.ts index f2ff420c3..4255c6ee0 100644 --- a/src/canvas/canvas-object-factory.ts +++ b/src/canvas/canvas-object-factory.ts @@ -286,7 +286,7 @@ export function createFabricObject( const safePathData = typeof node.d === 'string' && node.d.trim().length > 0 ? node.d - : 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0' + : 'M0 0 L0 0' const hasExplicitFill = node.fill && node.fill.length > 0 const strokeColor = resolveStrokeColor(node.stroke) const strokeWidth = resolveStrokeWidth(node.stroke) @@ -306,6 +306,7 @@ export function createFabricObject( strokeUniform: true, fillRule: 'evenodd', // Compound paths: inner sub-paths become transparent cutouts }) as FabricObjectWithPenId + ;(obj as any).__sourceD = safePathData // Cache native dimensions before scaling (Path width/height is derived from d) ;(obj as any).__nativeWidth = obj.width ;(obj as any).__nativeHeight = obj.height diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 08ed17b7e..258d942d7 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -825,9 +825,11 @@ export function useCanvasSync() { let obj: FabricObjectWithPenId | undefined let existingObj = objMap.get(node.id) + // Some object types require recreation when their underlying Fabric + // class/shape data changes (e.g. IText↔Textbox or Path.d changes). + let objectRecreated = false // Text nodes may need recreation when textGrowth mode changes // (IText ↔ Textbox are different Fabric classes). - let textRecreated = false if (existingObj && node.type === 'text') { const growth = node.textGrowth const w = typeof node.width === 'number' ? node.width : 0 @@ -836,7 +838,23 @@ export function useCanvasSync() { if (needsTextbox !== isTextbox) { canvas.remove(existingObj) existingObj = undefined - textRecreated = true + objectRecreated = true + } + } + + // Path nodes need recreation when `d` changes, because syncFabricObject + // updates paint/size but not the underlying path commands. + if (existingObj && node.type === 'path' && existingObj instanceof fabric.Path) { + const nextD = typeof node.d === 'string' ? node.d.trim() : '' + const tracked = (existingObj as any).__sourceD + const hasTrackedD = typeof tracked === 'string' + const prevD = hasTrackedD ? tracked.trim() : '' + const shouldRecreatePath = + (nextD.length > 0 && !hasTrackedD) || (hasTrackedD && prevD !== nextD) + if (shouldRecreatePath) { + canvas.remove(existingObj) + existingObj = undefined + objectRecreated = true } } @@ -871,8 +889,8 @@ export function useCanvasSync() { } else { canvas.add(newObj) } - // Restore Fabric selection when text object was recreated - if (textRecreated) { + // Restore Fabric selection when an object was recreated + if (objectRecreated) { const { activeId } = useCanvasStore.getState().selection if (activeId === node.id) { canvas.setActiveObject(newObj) diff --git a/src/services/ai/design-generator.ts b/src/services/ai/design-generator.ts index eddabd3de..5632ac281 100644 --- a/src/services/ai/design-generator.ts +++ b/src/services/ai/design-generator.ts @@ -1092,22 +1092,22 @@ function applyTreeFixesRecursive( ] as [number, number, number, number] } + const hasCjk13 = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(txt13) + const targetLh13 = hasCjk13 ? 1.25 : 1.2 + const targetPad13 = parsePaddingValues( + (frameUpdates13.padding as number | [number, number] | [number, number, number, number] | string | undefined) + ?? ('padding' in node ? node.padding : undefined), + ) const h13 = toSizeNumber(node.height, 0) - if (h13 > 0) { - const minCr13 = Math.max(6, Math.round(h13 * 0.22)) - const maxCr13 = Math.max(minCr13, Math.min(14, Math.round(h13 * 0.4))) - const cr13 = toCornerRadiusNumber(node.cornerRadius, 0) - if (cr13 <= 0) frameUpdates13.cornerRadius = maxCr13 - else if (cr13 > maxCr13) frameUpdates13.cornerRadius = maxCr13 - else if (cr13 < minCr13) frameUpdates13.cornerRadius = minCr13 - } + const effectiveH13 = h13 > 0 + ? h13 + : Math.round(fs13 * targetLh13 + targetPad13.top + targetPad13.bottom) + frameUpdates13.cornerRadius = Math.max(1, Math.round(effectiveH13 / 2)) if (Object.keys(frameUpdates13).length > 0) { updateNode(node.id, frameUpdates13 as Partial) } - const hasCjk13 = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(txt13) - const targetLh13 = hasCjk13 ? 1.25 : 1.2 const textUpdates13: Record = {} if (!t13.lineHeight || t13.lineHeight > 1.35 || t13.lineHeight < 1.05) { textUpdates13.lineHeight = targetLh13 @@ -1596,10 +1596,15 @@ function applyNavbarHeuristic(node: PenNode): void { function isBadgeLikeFrame(node: PenNode): boolean { if (node.type !== 'frame') return false - if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 4) return false + if (!Array.isArray(node.children) || node.children.length === 0 || node.children.length > 3) return false + + const marker = `${node.name ?? ''} ${node.id}`.toLowerCase() + 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) + if (explicitButtonMarker && !explicitBadgeMarker) return false const h = toSizeNumber(node.height, 0) - if (h > 0 && h > 60) return false + if (h > 0 && h > 40) return false const textChildren = node.children.filter( (c) => c.type === 'text' && typeof c.content === 'string' && c.content.trim().length > 0, @@ -1608,14 +1613,29 @@ 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 charCount = [...label].length + const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(label) const fontSize = textNode.fontSize ?? 14 - if (label.length > 28 || fontSize > 18) return false + const maxLabelChars = hasCjk ? 10 : 18 + if (charCount > maxLabelChars || fontSize > 16) return false const allowedChildren = node.children.every((c) => c.type === 'text' || c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle', ) if (!allowedChildren) return false + const iconCount = node.children.filter((c) => + c.type === 'path' || c.type === 'ellipse' || c.type === 'rectangle', + ).length + if (iconCount > 1) return false + + const w = toSizeNumber(node.width, 0) + if (w > 0 && w > 260) return false + + 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 + return node.layout === 'horizontal' || node.alignItems === 'center' || node.justifyContent === 'center' @@ -1742,15 +1762,10 @@ function applyBadgeSizing(node: PenNode): void { node.height = minFrameH } - // Avoid over-rounded pill corners on small badges. + // Capsule radius: half of badge height. const effectiveH = toSizeNumber(node.height, h > 0 ? h : minFrameH) if (effectiveH > 0) { - const minCr = Math.max(6, Math.round(effectiveH * 0.22)) - const maxCr = Math.max(minCr, Math.min(14, Math.round(effectiveH * 0.4))) - const currentCr = toCornerRadiusNumber(node.cornerRadius, 0) - if (currentCr <= 0) node.cornerRadius = maxCr - else if (currentCr > maxCr) node.cornerRadius = maxCr - else if (currentCr < minCr) node.cornerRadius = minCr + node.cornerRadius = Math.max(1, Math.round(effectiveH / 2)) } // Normalize icon children so they always render and stay visually balanced.