fix(canvas): recreate path objects on d change and refine badge detection

- Use invisible fallback path (M0 0 L0 0) instead of dot for empty d attr
- Track __sourceD on path objects to detect d changes requiring recreation
- Generalize textRecreated to objectRecreated for path recreation support
- Tighten isBadgeLikeFrame: max height 40px, max 3 children, 1 icon max,
  exclude explicit button names, width cap 260px, CJK label limit 10 chars
- Use full capsule radius (height/2) instead of clamped range for badges
This commit is contained in:
Fini 2026-02-22 16:33:49 +08:00
parent e99ac132b3
commit 0d9d20e8e8
3 changed files with 59 additions and 25 deletions

View file

@ -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

View file

@ -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)

View file

@ -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<PenNode>)
}
const hasCjk13 = /[\u4E00-\u9FFF\u3400-\u4DBF\u3000-\u303F\uFF00-\uFFEF]/.test(txt13)
const targetLh13 = hasCjk13 ? 1.25 : 1.2
const textUpdates13: Record<string, unknown> = {}
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.