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:
parent
e99ac132b3
commit
0d9d20e8e8
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue