openpencil/src/canvas/canvas-object-sync.ts
Kayshen Xu 4af8ef412b
V0.3.3 (#33)
* fix(ai): add icon name aliases and fix multi-path SVG concatenation

Add 55+ common icon name aliases (burger→hamburger, sushi→fish, etc.)
to both client icon-resolver and server icon API for robust AI-generated
icon resolution. Register Lucide's own aliases for broader coverage.

Fix SVG path concatenation bug where joining multiple <path> d-values
caused incorrect rendering — a standalone <path> treats initial lowercase
"m" as absolute, but after concatenation it becomes relative to the
previous sub-path endpoint. Now ensures each sub-path starts with
absolute "M".

Add tryAsyncIconFontResolution for icon_font nodes that miss local
lookup — fetches from server API, caches result, and triggers canvas
re-render.

* fix(canvas): preserve badge/overlay absolute positioning in auto-layout

Add isBadgeOverlayNode() detector for badge, indicator, notification-dot,
and overlay nodes. These nodes now retain their x/y coordinates instead
of being stripped by layout sanitization.

Update computeLayoutPositions to exclude badge nodes from the layout flow
— they keep absolute positioning and render on top (prepended for correct
z-order in reverse iteration).

* fix(ai): prevent duplicate canvas objects and fix emoji-to-icon pipeline

Streaming path: add ensureUniqueNodeIds before inserting nodes to prevent
ID collisions across multiple AI generations. Track newly inserted IDs
so subsequent streaming nodes don't collide either.

Canvas sync: deduplicate Fabric objects sharing the same penNodeId —
keep only the one tracked in objMap, remove stale duplicates.

Badge nodes: use shared isBadgeOverlayNode() for z-order insertion
and skip x/y stripping in layout parents.

Fix emoji-to-icon pipeline: re-run applyIconPathResolution after
applyNoEmojiIconHeuristic converts emoji text nodes to path nodes,
so the icon resolver can match by name (e.g. "Pizza Emoji Path" → pizza).

* fix(canvas): add async icon resolution fallback for icon_font nodes

When lookupIconByName fails locally, queue tryAsyncIconFontResolution
to fetch from server API. Cache result in ICON_PATH_MAP and trigger
canvas re-render via store update. Store iconFontName and iconStyle
on Fabric object for sync tracking.

* fix(ai): strengthen emoji ban in prompts and improve orchestrator defaults

Update all AI prompts to explicitly ban emoji characters with concrete
examples and redirect to icon_font nodes instead of the previously
incorrect "path nodes" guidance.

Add z-order rule to orchestrator prompt: overlay elements must come
before content they overlap.

Add padding support to OrchestratorPlan rootFrame type. Default mobile
root frame gap to 16 for consistent spacing.

* feat(electron): add publisher name to Windows build configuration

Updated the `electron-builder.yml` to include a publisher name for Windows builds, enhancing the identification of the application during installation. Additionally, revised the README files across multiple languages to reflect the new project description and features, emphasizing OpenPencil as the world's first AI-native open-source vector design tool with concurrent agent teams and design-as-code capabilities.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
2026-03-11 21:18:49 +08:00

284 lines
10 KiB
TypeScript

import * as fabric from 'fabric'
import type { PenNode } from '@/types/pen'
import { buildEllipseArcPath, isArcEllipse } from '@/utils/arc-path'
import type { FabricObjectWithPenId } from './canvas-object-factory'
import {
resolveFill,
resolveFillColor,
resolveShadow,
resolveStrokeColor,
resolveStrokeWidth,
computeImageTransform,
} from './canvas-object-factory'
function sizeToNumber(
val: number | string | undefined,
fallback: number,
): number {
if (typeof val === 'number') return val
if (typeof val === 'string') {
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
if (m) return parseFloat(m[1])
const n = parseFloat(val)
if (!isNaN(n)) return n
}
return fallback
}
function cornerRadiusValue(
cr: number | [number, number, number, number] | undefined,
): number {
if (cr === undefined) return 0
if (typeof cr === 'number') return cr
return cr[0]
}
function shouldSplitByGrapheme(text: string): boolean {
const hasCjk = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]/.test(text)
const hasLongCjkRun = /[\u3400-\u4DBF\u4E00-\u9FFF\u3040-\u30FF\uAC00-\uD7AF]{4,}/.test(text)
return hasCjk && hasLongCjkRun
}
function isFixedWidthText(node: PenNode): boolean {
if (node.type !== 'text') return false
return node.textGrowth === 'fixed-width' || node.textGrowth === 'fixed-width-height'
}
export function syncFabricObject(
obj: FabricObjectWithPenId,
node: PenNode,
) {
const visible = ('visible' in node ? node.visible : undefined) !== false
const locked = ('locked' in node ? node.locked : undefined) === true
const effects = 'effects' in node ? node.effects : undefined
const shadow = resolveShadow(effects)
obj.set({
left: node.x ?? obj.left,
top: node.y ?? obj.top,
angle: node.rotation ?? 0,
opacity: typeof node.opacity === 'number' ? node.opacity : 1,
visible,
selectable: !locked,
evented: !locked,
})
obj.shadow = shadow ?? null
switch (node.type) {
case 'frame': {
// Frames without explicit fill are transparent containers
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
const hasFill = node.fill && node.fill.length > 0
obj.set({
width: w,
height: h,
fill: hasFill ? resolveFill(node.fill, w, h) : 'transparent',
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
if ('rx' in obj) {
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
obj.set({ rx: r, ry: r })
}
break
}
case 'rectangle':
case 'group': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
obj.set({
width: w,
height: h,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
if ('rx' in obj) {
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
obj.set({ rx: r, ry: r })
}
break
}
case 'ellipse': {
const w = sizeToNumber(node.width, 100)
const h = sizeToNumber(node.height, 100)
if (isArcEllipse(node.startAngle, node.sweepAngle, node.innerRadius)) {
// Arc ellipse rendered as Fabric.Path — update path data
const arcD = buildEllipseArcPath(w, h, node.startAngle ?? 0, node.sweepAngle ?? 360, node.innerRadius ?? 0)
if (obj instanceof fabric.Path) {
const trackedD = typeof (obj as any).__sourceD === 'string' ? (obj as any).__sourceD.trim() : ''
if (arcD !== trackedD) {
const tmp = new fabric.Path(arcD)
;(obj as any).path = (tmp as any).path
;(obj as any).__sourceD = arcD
;(obj as any).__nativeWidth = w
;(obj as any).__nativeHeight = h
}
}
// Override Fabric's auto-computed bounding box — the arc path is
// drawn within a 0,0 → w,h coordinate space.
;(obj as any).pathOffset = new fabric.Point(w / 2, h / 2)
obj.set({
width: w,
height: h,
scaleX: 1,
scaleY: 1,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
} else {
obj.set({
rx: w / 2,
ry: h / 2,
fill: resolveFill(node.fill, w, h),
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
}
break
}
case 'line': {
obj.set({
x1: node.x ?? 0,
y1: node.y ?? 0,
x2: node.x2 ?? 100,
y2: node.y2 ?? 0,
stroke: resolveStrokeColor(node.stroke),
strokeWidth: resolveStrokeWidth(node.stroke),
})
break
}
case 'text': {
const content =
typeof node.content === 'string'
? node.content
: node.content.map((s) => s.text).join('')
const w = sizeToNumber(node.width, 0)
const fixedWidthText = isFixedWidthText(node)
const fontSize = node.fontSize ?? 16
const splitByGrapheme = shouldSplitByGrapheme(content)
obj.set({
text: content,
fontFamily: node.fontFamily ?? 'Inter, sans-serif',
fontSize,
fontWeight: (node.fontWeight as string) ?? 'normal',
fontStyle: node.fontStyle ?? 'normal',
fill: resolveFillColor(node.fill),
textAlign: node.textAlign ?? 'left',
lineHeight: node.lineHeight ?? 1.2,
charSpacing: node.letterSpacing
? (node.letterSpacing / fontSize) * 1000
: 0,
})
if (obj instanceof fabric.Textbox) {
obj.set({ splitByGrapheme } as Partial<fabric.Textbox>)
if (fixedWidthText && w > 0) obj.set({ width: w })
}
break
}
case 'image': {
const w = sizeToNumber(node.width, 200)
const h = sizeToNumber(node.height, 200)
const r = Math.min(cornerRadiusValue(node.cornerRadius), h / 2)
const fitMode = node.objectFit ?? 'fill'
// Detect mode changes that require object recreation (e.g. tile ↔ non-tile)
const prevMode = (obj as any).__objectFit ?? 'fill'
if (prevMode !== fitMode) {
;(obj as any).__needsRecreation = true
return
}
// Tile mode: update pattern fill on the Rect
if (fitMode === 'tile') {
obj.set({ width: w, height: h, rx: r, ry: r, dirty: true })
break
}
// Fill/Fit/Crop: use computeImageTransform with native (source) dimensions
const nw = (obj as any).__nativeWidth || obj.width || w
const nh = (obj as any).__nativeHeight || obj.height || h
const transform = computeImageTransform(nw, nh, w, h, fitMode, r)
obj.set({
cropX: transform.cropX,
cropY: transform.cropY,
width: transform.cropWidth,
height: transform.cropHeight,
scaleX: transform.scaleX,
scaleY: transform.scaleY,
})
// clipPath only for corner radius (fill/crop overflow handled by cropX/cropY)
if (transform.clipPath) {
obj.set({
clipPath: transform.clipPath,
objectCaching: false,
dirty: true,
})
} else {
obj.set({
clipPath: undefined,
objectCaching: true,
dirty: true,
})
}
break
}
case 'polygon':
case 'path': {
// Update path data in-place when `d` changes — avoids object recreation
// and preserves selection, position, and Fabric object identity.
if (obj instanceof fabric.Path && node.type === 'path') {
const nextD = typeof node.d === 'string' ? node.d.trim() : ''
const trackedD = typeof (obj as any).__sourceD === 'string' ? (obj as any).__sourceD.trim() : ''
if (nextD && nextD !== trackedD) {
// Parse into a temporary Path to get the new internal representation
const tmp = new fabric.Path(nextD)
;(obj as any).path = (tmp as any).path
obj.width = tmp.width
obj.height = tmp.height
;(obj as any).pathOffset = (tmp as any).pathOffset
;(obj as any).__sourceD = nextD
;(obj as any).__nativeWidth = tmp.width
;(obj as any).__nativeHeight = tmp.height
}
}
const w = sizeToNumber('width' in node ? node.width : undefined, 100)
const h = sizeToNumber('height' in node ? node.height : undefined, 100)
const hasExplicitFill = node.type === 'path' && 'fill' in node && node.fill && node.fill.length > 0
const strokeColor = resolveStrokeColor('stroke' in node ? node.stroke : undefined)
const strokeWidth = resolveStrokeWidth('stroke' in node ? node.stroke : undefined)
const hasVisibleStroke = node.type === 'path' && strokeWidth > 0 && !!strokeColor
// For path nodes: stroke-only icons must not get a default fill
const fill = node.type === 'path' && !hasExplicitFill && hasVisibleStroke
? 'transparent'
: resolveFill('fill' in node ? node.fill : undefined, w, h)
obj.set({
fill,
stroke: hasVisibleStroke ? strokeColor : undefined,
strokeWidth: hasVisibleStroke ? strokeWidth : 0,
...(node.type === 'path' ? { strokeUniform: true, fillRule: 'evenodd' } : {}),
})
// Use cached native dimensions (from path/points data) to compute correct
// scale, even if obj.width was previously corrupted by scale baking.
const nw = (obj as any).__nativeWidth || obj.width
const nh = (obj as any).__nativeHeight || obj.height
if (w > 0 && h > 0 && nw && nh) {
if (node.type === 'path') {
// Uniform scale — preserve aspect ratio so icons don't get squished
const uniformScale = Math.min(w / nw, h / nh)
// Keep native width/height to avoid pathOffset drift that can visually
// offset icons inside logo containers.
obj.set({ width: nw, height: nh, scaleX: uniformScale, scaleY: uniformScale })
} else {
obj.set({ width: nw, height: nh, scaleX: w / nw, scaleY: h / nh })
}
}
break
}
}
obj.setCoords()
}