mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
V0.3.3 (#32)
* 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. --------- Co-authored-by: Fini <fini.yang@gmail.com>
This commit is contained in:
parent
2b35c86618
commit
669c0dd38d
11 changed files with 314 additions and 23 deletions
|
|
@ -51,9 +51,79 @@ export default defineEventHandler(async (event) => {
|
|||
return { icon: result }
|
||||
})
|
||||
|
||||
// Common name aliases for icons AI models frequently request.
|
||||
// Keep in sync with commonAliases in src/services/ai/icon-resolver.ts
|
||||
const NAME_ALIASES: Record<string, string> = {
|
||||
burger: 'hamburger',
|
||||
sushi: 'fish',
|
||||
ramen: 'soup',
|
||||
noodle: 'soup',
|
||||
noodles: 'soup',
|
||||
steak: 'beef',
|
||||
meat: 'beef',
|
||||
icecream: 'ice-cream-cone',
|
||||
donut: 'donut',
|
||||
bread: 'croissant',
|
||||
fruit: 'apple',
|
||||
food: 'utensils',
|
||||
drink: 'cup-soda',
|
||||
coffee: 'coffee',
|
||||
tea: 'cup-soda',
|
||||
restaurant: 'utensils-crossed',
|
||||
delivery: 'truck',
|
||||
order: 'clipboard-list',
|
||||
recipe: 'book-open',
|
||||
grocery: 'shopping-basket',
|
||||
cart: 'shopping-cart',
|
||||
bag: 'shopping-bag',
|
||||
pay: 'credit-card',
|
||||
payment: 'credit-card',
|
||||
wallet: 'wallet',
|
||||
money: 'banknote',
|
||||
coupon: 'ticket',
|
||||
discount: 'percent',
|
||||
rating: 'star',
|
||||
review: 'message-square',
|
||||
favorite: 'heart',
|
||||
favourites: 'heart',
|
||||
favorites: 'heart',
|
||||
notification: 'bell',
|
||||
address: 'map-pin',
|
||||
navigate: 'navigation',
|
||||
directions: 'map',
|
||||
logout: 'log-out',
|
||||
login: 'log-in',
|
||||
signup: 'user-plus',
|
||||
account: 'user',
|
||||
password: 'key',
|
||||
security: 'shield',
|
||||
privacy: 'eye-off',
|
||||
about: 'info',
|
||||
faq: 'help-circle',
|
||||
support: 'headphones',
|
||||
contact: 'phone',
|
||||
feedback: 'message-circle',
|
||||
language: 'globe',
|
||||
theme: 'palette',
|
||||
darkmode: 'moon',
|
||||
lightmode: 'sun',
|
||||
sound: 'volume-2',
|
||||
mute: 'volume-x',
|
||||
wifi: 'wifi',
|
||||
bluetooth: 'bluetooth',
|
||||
battery: 'battery',
|
||||
location: 'map-pin',
|
||||
gps: 'locate',
|
||||
scan: 'scan',
|
||||
qrcode: 'qr-code',
|
||||
barcode: 'barcode',
|
||||
}
|
||||
|
||||
function resolveIcon(name: string): IconResult | null {
|
||||
const kebab = toKebabCase(name)
|
||||
const candidates = kebab !== name ? [name, kebab] : [name]
|
||||
const aliased = NAME_ALIASES[name] ?? NAME_ALIASES[kebab]
|
||||
const candidates = new Set([name, kebab])
|
||||
if (aliased) candidates.add(aliased)
|
||||
|
||||
// 1. Try simple-icons first (brand/product icons).
|
||||
// simple-icons only contains brand logos, so a hit here is unambiguously
|
||||
|
|
@ -124,6 +194,14 @@ function parseIconBody(
|
|||
hasFill = true
|
||||
}
|
||||
|
||||
// When joining multiple <path> d-values, ensure each sub-path starts with
|
||||
// absolute M. A standalone <path> treats initial lowercase "m" as absolute,
|
||||
// but after concatenation it becomes relative to the previous endpoint.
|
||||
for (let i = 1; i < paths.length; i++) {
|
||||
if (paths[i].startsWith('m')) {
|
||||
paths[i] = 'M' + paths[i].slice(1)
|
||||
}
|
||||
}
|
||||
const d = paths.join(' ')
|
||||
const style: 'stroke' | 'fill' = hasStroke && !hasFill ? 'stroke' : 'fill'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { PenNode, ContainerProps } from '@/types/pen'
|
||||
import { isBadgeOverlayNode } from '@/services/ai/design-node-sanitization'
|
||||
import { useDocumentStore, DEFAULT_FRAME_ID } from '@/stores/document-store'
|
||||
import {
|
||||
parseSizing,
|
||||
|
|
@ -263,6 +264,12 @@ export function computeLayoutPositions(
|
|||
const layout = c.layout || inferLayout(parent)
|
||||
if (!layout || layout === 'none') return visibleChildren
|
||||
|
||||
// Separate badge/overlay nodes from layout children — badges use absolute
|
||||
// positioning and should not participate in the layout flow.
|
||||
const badgeNodes = visibleChildren.filter(isBadgeOverlayNode)
|
||||
const layoutChildren = visibleChildren.filter((ch) => !isBadgeOverlayNode(ch))
|
||||
if (layoutChildren.length === 0) return visibleChildren
|
||||
|
||||
const pW = parseSizing(c.width)
|
||||
const pH = parseSizing(c.height)
|
||||
// When parent has no explicit dimensions (fit_content), resolve actual size
|
||||
|
|
@ -279,10 +286,10 @@ export function computeLayoutPositions(
|
|||
const availW = parentW - pad.left - pad.right
|
||||
const availH = parentH - pad.top - pad.bottom
|
||||
const availMain = isVertical ? availH : availW
|
||||
const totalGapSpace = gap * Math.max(0, visibleChildren.length - 1)
|
||||
const totalGapSpace = gap * Math.max(0, layoutChildren.length - 1)
|
||||
|
||||
// Two-pass sizing: first compute fixed sizes, then allocate remaining space for fill children
|
||||
const mainSizing = visibleChildren.map((ch) => {
|
||||
const mainSizing = layoutChildren.map((ch) => {
|
||||
const prop = isVertical ? 'height' : 'width'
|
||||
if (prop in ch) {
|
||||
const s = parseSizing((ch as any)[prop])
|
||||
|
|
@ -298,7 +305,7 @@ export function computeLayoutPositions(
|
|||
const remainingMain = Math.max(0, availMain - fixedTotal - totalGapSpace)
|
||||
const fillSize = fillCount > 0 ? remainingMain / fillCount : 0
|
||||
|
||||
const sizes = visibleChildren.map((ch, i) => {
|
||||
const sizes = layoutChildren.map((ch, i) => {
|
||||
let mainSize = mainSizing[i] === 'fill' ? fillSize : (mainSizing[i] as number)
|
||||
// For single-line text in vertical layouts, use Fabric's actual rendered
|
||||
// height (fontSize * 1.13) instead of fontSize * lineHeight. This ensures
|
||||
|
|
@ -334,14 +341,14 @@ export function computeLayoutPositions(
|
|||
break
|
||||
case 'space_between':
|
||||
effectiveGap =
|
||||
visibleChildren.length > 1
|
||||
? (availMain - totalMain) / (visibleChildren.length - 1)
|
||||
layoutChildren.length > 1
|
||||
? (availMain - totalMain) / (layoutChildren.length - 1)
|
||||
: 0
|
||||
break
|
||||
case 'space_around': {
|
||||
const spacing =
|
||||
visibleChildren.length > 0
|
||||
? (availMain - totalMain) / visibleChildren.length
|
||||
layoutChildren.length > 0
|
||||
? (availMain - totalMain) / layoutChildren.length
|
||||
: 0
|
||||
mainPos = spacing / 2
|
||||
effectiveGap = spacing
|
||||
|
|
@ -352,7 +359,7 @@ export function computeLayoutPositions(
|
|||
break
|
||||
}
|
||||
|
||||
return visibleChildren.map((child, i) => {
|
||||
const positioned = layoutChildren.map((child, i) => {
|
||||
const size = sizes[i]
|
||||
const crossAvail = isVertical ? availW : availH
|
||||
const childCross = isVertical ? size.w : size.h
|
||||
|
|
@ -430,6 +437,14 @@ export function computeLayoutPositions(
|
|||
|
||||
return out as unknown as PenNode
|
||||
})
|
||||
|
||||
// Prepend badge/overlay nodes (they keep original x/y for absolute positioning).
|
||||
// flattenNodes iterates in REVERSE, so index 0 = frontmost z-order.
|
||||
// Badges at the beginning render on top of layout children.
|
||||
if (badgeNodes.length > 0) {
|
||||
return [...badgeNodes, ...positioned]
|
||||
}
|
||||
return positioned
|
||||
}
|
||||
|
||||
function normalizeJustifyContent(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from './canvas-constants'
|
||||
import { defaultLineHeight } from './canvas-text-measure'
|
||||
import { applyRotationControls } from './canvas-controls'
|
||||
import { lookupIconByName } from '@/services/ai/icon-resolver'
|
||||
import { lookupIconByName, tryAsyncIconFontResolution } from '@/services/ai/icon-resolver'
|
||||
|
||||
function angleToCoords(
|
||||
angleDeg: number,
|
||||
|
|
@ -468,6 +468,10 @@ export function createFabricObject(
|
|||
const iconMatch = lookupIconByName(iconName)
|
||||
const iconD = iconMatch?.d ?? 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0'
|
||||
const iconStyle = iconMatch?.style ?? 'stroke'
|
||||
// Queue async resolution when local lookup fails — result cached for future lookups
|
||||
if (!iconMatch && iconName) {
|
||||
tryAsyncIconFontResolution(node.id, iconName)
|
||||
}
|
||||
const pw = sizeToNumber(node.width, 20)
|
||||
const ph = sizeToNumber(node.height, 20)
|
||||
|
||||
|
|
@ -495,6 +499,8 @@ export function createFabricObject(
|
|||
}) as FabricObjectWithPenId
|
||||
;(obj as any).__nativeWidth = obj.width
|
||||
;(obj as any).__nativeHeight = obj.height
|
||||
;(obj as any).__iconFontName = iconName
|
||||
;(obj as any).__iconStyle = iconStyle
|
||||
if (pw > 0 && ph > 0 && obj.width && obj.height) {
|
||||
const uniformScale = Math.min(pw / obj.width, ph / obj.height)
|
||||
obj.set({ scaleX: uniformScale, scaleY: uniformScale })
|
||||
|
|
|
|||
|
|
@ -547,9 +547,17 @@ export function useCanvasSync() {
|
|||
}
|
||||
})(pageChildren)
|
||||
|
||||
// Remove objects that no longer exist in the document
|
||||
// Remove objects that no longer exist in the document, and
|
||||
// deduplicate: when multiple Fabric objects share the same penNodeId
|
||||
// (e.g. from ID collisions across separate AI generations), keep only
|
||||
// the one tracked in objMap and remove the rest.
|
||||
for (const obj of objects) {
|
||||
if (obj.penNodeId && !nodeMap.has(obj.penNodeId)) {
|
||||
if (!obj.penNodeId) continue
|
||||
if (!nodeMap.has(obj.penNodeId)) {
|
||||
canvas.remove(obj)
|
||||
} else if (objMap.get(obj.penNodeId) !== obj) {
|
||||
// Duplicate — this object has the same penNodeId but isn't the one
|
||||
// tracked in objMap (Map keeps the last occurrence). Remove it.
|
||||
canvas.remove(obj)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ ICONS & IMAGES:
|
|||
- Icons: Use "path" nodes. Size 16-24px. CRITICAL: ONLY use names from the Feather icon library below — these are bundled locally and render instantly. Convert the icon name to PascalCase + "Icon" suffix (e.g. "search" → "SearchIcon", "arrow-right" → "ArrowRightIcon"). Do NOT invent names outside this list.
|
||||
The system auto-resolves icon names to verified SVG paths — the "name" field is what matters; "d" is replaced automatically.
|
||||
Available Feather icons: ${FEATHER_ICON_NAMES}
|
||||
- Never use emoji characters as icons (e.g. 🧠✨📱✅). Always use path nodes for icons.
|
||||
- NEVER use emoji characters as icons (e.g. 🍕🍔⭐✅🔔). Always use icon_font nodes — emoji cannot render on canvas.
|
||||
- For app screenshot/mockup areas, use a phone placeholder frame with solid fill matching the page theme + 1px subtle stroke. cornerRadius ~32. Prefer no inner content; if a placeholder copy is needed (e.g. "APP截图占位"), keep exactly one centered text node INSIDE the phone frame (never as a sibling below it).
|
||||
- Do NOT use random real-world app screenshots or dense mini-app simulations for showcase sections.
|
||||
`
|
||||
|
|
@ -221,7 +221,7 @@ DESIGN GUIDELINES:
|
|||
- Avoid repeating the exact same palette across unrelated designs
|
||||
- Navigation bars (when designing landing pages/websites): use justifyContent="space_between" with 3 child groups (logo-group | links-group | cta-button), padding=[0,80], alignItems="center". This auto-distributes them perfectly across the full width.
|
||||
- Icons: use "path" nodes with Feather icon names only (full list in the ICONS & IMAGES section above). Size 16-24px.
|
||||
- Never use emoji glyphs as icon substitutes. If an icon is needed, use a path node with a descriptive icon name.
|
||||
- NEVER use emoji glyphs as icon substitutes (🍕🍔⭐ etc). If an icon is needed, use an icon_font node with iconFontName (lucide name). Emoji cannot render on canvas.
|
||||
- Use image nodes for generic photos/illustrations only; for app preview areas prefer phone mockup placeholders
|
||||
- Phone mockup/screenshot placeholder: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill matching theme + 1px subtle stroke. NEVER use ellipse or circle for mockups. If a placeholder label is used, keep exactly ONE centered text child inside the phone frame; otherwise no children. Never put the label as a sibling below the phone.
|
||||
- Hero with phone mockup (desktop): prefer a two-column horizontal layout (left text/cta, right phone). Do NOT stack the phone below headline unless mobile.
|
||||
|
|
@ -294,7 +294,7 @@ CRITICAL RULES:
|
|||
- Phone mockup: exactly ONE "frame" node, width 260-300, height 520-580, cornerRadius 32, solid fill + 1px stroke. NEVER use ellipse. If a placeholder label is needed, allow exactly ONE centered text child inside the phone; otherwise no children. Never put placeholder text below the phone as a sibling. ONLY use phone mockups for app showcase/marketing sections. When the user says "mobile screen" / "移动端" / "手机页面", build the ACTUAL mobile UI at 375x812 — NOT a desktop page with a phone mockup.
|
||||
- NEVER use ellipse for decorative/placeholder shapes — use frame or rectangle with cornerRadius.
|
||||
- Navigation bars (when applicable): justifyContent="space_between", 3 groups (logo | links | CTA), padding=[0,80], alignItems="center".
|
||||
- Never use emoji as icons; use path nodes with descriptive icon names (system auto-resolves to verified SVG paths).
|
||||
- NEVER use emoji as icons (🍕🍔⭐✅🔔 etc); use icon_font nodes with iconFontName. Emoji cannot render on canvas.
|
||||
- TEXT IN LAYOUTS: vertical layout body text should use textGrowth="fixed-width" + width="fill_container". Horizontal layout labels/buttons should use textGrowth="auto" + width="fit_content" (or omit width). NEVER use fixed pixel widths on text.
|
||||
- TEXT HEIGHT: NEVER set explicit pixel height on text nodes (e.g. height:22). OMIT the height property — the engine auto-calculates from textGrowth + content. A small explicit height causes text clipping and overlap.
|
||||
- Cards with images: ALWAYS set clipContent: true + cornerRadius. Use "fill_container" width on image/body/text children inside the card.
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ export interface OrchestratorPlan {
|
|||
height: number
|
||||
layout?: 'none' | 'vertical' | 'horizontal'
|
||||
gap?: number
|
||||
padding?: number | [number, number] | [number, number, number, number]
|
||||
fill?: Array<{ type: string; color: string }>
|
||||
}
|
||||
styleGuide?: StyleGuide
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
sanitizeLayoutChildPositions,
|
||||
sanitizeScreenFrameBounds,
|
||||
hasActiveLayout,
|
||||
isBadgeOverlayNode,
|
||||
} from './design-node-sanitization'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -127,6 +128,26 @@ export function insertStreamingNode(
|
|||
const { addNode, getNodeById } = useDocumentStore.getState()
|
||||
normalizeNodeFills(node)
|
||||
|
||||
// Ensure unique node IDs to avoid collisions with pre-existing canvas content.
|
||||
// The upsert path already does this in sanitizeNodesForUpsert, but the streaming
|
||||
// path was missing it — causing duplicate Fabric objects when two generations
|
||||
// produce nodes with the same IDs (e.g. "header-title" in both FoodHome and Settings).
|
||||
const streamCounters = new Map<string, number>()
|
||||
const streamRemaps = new Map<string, string>()
|
||||
ensureUniqueNodeIds(node, preExistingNodeIds, streamCounters, streamRemaps)
|
||||
// Track the newly inserted IDs so subsequent streaming nodes don't collide either
|
||||
const trackNewIds = (n: PenNode) => {
|
||||
preExistingNodeIds.add(n.id)
|
||||
if ('children' in n && Array.isArray(n.children)) {
|
||||
for (const child of n.children) trackNewIds(child)
|
||||
}
|
||||
}
|
||||
trackNewIds(node)
|
||||
// Merge any remappings into the generation-wide remap table
|
||||
for (const [from, to] of streamRemaps) {
|
||||
generationRemappedIds.set(from, to)
|
||||
}
|
||||
|
||||
// Ensure container nodes have children array for later child insertions
|
||||
if ((node.type === 'frame' || node.type === 'group') && !('children' in node)) {
|
||||
;(node as PenNode & { children: PenNode[] }).children = []
|
||||
|
|
@ -141,7 +162,7 @@ export function insertStreamingNode(
|
|||
? getNodeById(resolvedParent)
|
||||
: null
|
||||
|
||||
if (parentNode && hasActiveLayout(parentNode)) {
|
||||
if (parentNode && hasActiveLayout(parentNode) && !isBadgeOverlayNode(node)) {
|
||||
if ('x' in node) delete (node as { x?: number }).x
|
||||
if ('y' in node) delete (node as { y?: number }).y
|
||||
// Text defaults inside layout frames:
|
||||
|
|
@ -248,8 +269,9 @@ export function insertStreamingNode(
|
|||
startNewAnimationBatch()
|
||||
}
|
||||
|
||||
// Append (not prepend) so auto-layout children stay in generation order
|
||||
addNode(insertParent, node, Infinity)
|
||||
// Badge/overlay nodes prepend (index 0) so they render on top (earlier = higher z-order).
|
||||
// All other nodes append to preserve auto-layout generation order.
|
||||
addNode(insertParent, node, isBadgeOverlayNode(node) ? 0 : Infinity)
|
||||
|
||||
// When a frame is inserted into a horizontal layout, equalize sibling card widths
|
||||
// to prevent overflow when multiple cards are placed in the same row.
|
||||
|
|
@ -444,6 +466,12 @@ export function applyGenerationHeuristics(node: PenNode): void {
|
|||
|
||||
applyIconPathResolution(node)
|
||||
applyNoEmojiIconHeuristic(node)
|
||||
// Re-run icon resolution on nodes converted from emoji text → path by the
|
||||
// heuristic above. applyNoEmojiIconHeuristic sets a circle fallback path;
|
||||
// the icon resolver can often match the name (e.g. "Pizza Emoji Path" → pizza).
|
||||
if (node.type === 'path') {
|
||||
applyIconPathResolution(node)
|
||||
}
|
||||
applyImagePlaceholderHeuristic(node)
|
||||
|
||||
if (!('children' in node) || !Array.isArray(node.children)) return
|
||||
|
|
|
|||
|
|
@ -69,11 +69,25 @@ export function hasActiveLayout(node: PenNode): boolean {
|
|||
return node.layout === 'vertical' || node.layout === 'horizontal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a badge/overlay that should use absolute positioning
|
||||
* instead of participating in the parent's layout flow.
|
||||
*/
|
||||
export function isBadgeOverlayNode(node: PenNode): boolean {
|
||||
if ('role' in node) {
|
||||
const role = (node as { role?: string }).role
|
||||
if (role === 'badge' || role === 'pill' || role === 'tag') return true
|
||||
}
|
||||
const name = (node.name ?? '').toLowerCase()
|
||||
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
|
||||
}
|
||||
|
||||
export function sanitizeLayoutChildPositions(
|
||||
node: PenNode,
|
||||
parentHasLayout: boolean,
|
||||
): void {
|
||||
if (parentHasLayout) {
|
||||
// Badge/overlay nodes retain their x/y for absolute positioning
|
||||
if (parentHasLayout && !isBadgeOverlayNode(node)) {
|
||||
if ('x' in node) delete (node as { x?: number }).x
|
||||
if ('y' in node) delete (node as { y?: number }).y
|
||||
}
|
||||
|
|
|
|||
|
|
@ -248,7 +248,17 @@ function iconifyBodyToPathD(body: string): string | null {
|
|||
parts.push(cmds.join(' '))
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(' ') : null
|
||||
if (parts.length === 0) return null
|
||||
// When joining multiple <path> d-values, ensure each sub-path starts with
|
||||
// absolute M (uppercase). A standalone <path> treats initial lowercase "m"
|
||||
// as absolute, but after concatenation it becomes relative to the previous
|
||||
// sub-path's endpoint — drawing strokes in wrong positions.
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
if (parts[i].startsWith('m')) {
|
||||
parts[i] = 'M' + parts[i].slice(1)
|
||||
}
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
// Populate ICON_PATH_MAP with Lucide icons first (1700+), then Feather as fallback.
|
||||
|
|
@ -267,6 +277,92 @@ function iconifyBodyToPathD(body: string): string | null {
|
|||
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = entry
|
||||
}
|
||||
|
||||
// Lucide aliases — map alternate names to their parent icon (e.g. ice-cream → ice-cream-cone)
|
||||
const lucideAliases = (lucideData as { aliases?: Record<string, { parent: string }> }).aliases
|
||||
if (lucideAliases) {
|
||||
for (const [alias, meta] of Object.entries(lucideAliases)) {
|
||||
const parentEntry = ICON_PATH_MAP[meta.parent] ?? ICON_PATH_MAP[meta.parent.replace(/-/g, '')]
|
||||
if (!parentEntry) continue
|
||||
if (!ICON_PATH_MAP[alias]) ICON_PATH_MAP[alias] = parentEntry
|
||||
const normalized = alias.replace(/-/g, '')
|
||||
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = parentEntry
|
||||
}
|
||||
}
|
||||
|
||||
// Common aliases that don't exist in Lucide/Feather but are frequently used by AI
|
||||
// Keep in sync with NAME_ALIASES in server/api/ai/icon.ts
|
||||
const commonAliases: Record<string, string> = {
|
||||
burger: 'hamburger',
|
||||
sushi: 'fish',
|
||||
ramen: 'soup',
|
||||
noodle: 'soup',
|
||||
noodles: 'soup',
|
||||
steak: 'beef',
|
||||
meat: 'beef',
|
||||
icecream: 'ice-cream-cone',
|
||||
donut: 'donut',
|
||||
bread: 'croissant',
|
||||
fruit: 'apple',
|
||||
food: 'utensils',
|
||||
drink: 'cup-soda',
|
||||
coffee: 'coffee',
|
||||
tea: 'cup-soda',
|
||||
restaurant: 'utensils-crossed',
|
||||
delivery: 'truck',
|
||||
order: 'clipboard-list',
|
||||
recipe: 'book-open',
|
||||
grocery: 'shopping-basket',
|
||||
cart: 'shopping-cart',
|
||||
bag: 'shopping-bag',
|
||||
pay: 'credit-card',
|
||||
payment: 'credit-card',
|
||||
wallet: 'wallet',
|
||||
money: 'banknote',
|
||||
coupon: 'ticket',
|
||||
discount: 'percent',
|
||||
rating: 'star',
|
||||
review: 'message-square',
|
||||
favorite: 'heart',
|
||||
favourites: 'heart',
|
||||
favorites: 'heart',
|
||||
notification: 'bell',
|
||||
address: 'map-pin',
|
||||
navigate: 'navigation',
|
||||
directions: 'map',
|
||||
logout: 'log-out',
|
||||
login: 'log-in',
|
||||
signup: 'user-plus',
|
||||
account: 'user',
|
||||
password: 'key',
|
||||
security: 'shield',
|
||||
privacy: 'eye-off',
|
||||
about: 'info',
|
||||
faq: 'help-circle',
|
||||
support: 'headphones',
|
||||
contact: 'phone',
|
||||
feedback: 'message-circle',
|
||||
language: 'globe',
|
||||
theme: 'palette',
|
||||
darkmode: 'moon',
|
||||
lightmode: 'sun',
|
||||
sound: 'volume-2',
|
||||
mute: 'volume-x',
|
||||
wifi: 'wifi',
|
||||
bluetooth: 'bluetooth',
|
||||
battery: 'battery',
|
||||
location: 'map-pin',
|
||||
gps: 'locate',
|
||||
scan: 'scan',
|
||||
qrcode: 'qr-code',
|
||||
barcode: 'barcode',
|
||||
}
|
||||
for (const [alias, target] of Object.entries(commonAliases)) {
|
||||
const targetEntry = ICON_PATH_MAP[target] ?? ICON_PATH_MAP[target.replace(/-/g, '')]
|
||||
if (targetEntry && !ICON_PATH_MAP[alias]) {
|
||||
ICON_PATH_MAP[alias] = targetEntry
|
||||
}
|
||||
}
|
||||
|
||||
// Feather — fallback for any names not covered by Lucide
|
||||
const featherIcons = (featherData as { icons: Record<string, { body: string }> }).icons
|
||||
for (const [name, icon] of Object.entries(featherIcons)) {
|
||||
|
|
@ -340,6 +436,47 @@ function tryImmediateIconResolution(nodeId: string, iconName: string): void {
|
|||
.catch(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an icon_font node for async resolution when lookupIconByName fails.
|
||||
* Fetches from /api/ai/icon, caches in ICON_PATH_MAP for future lookups,
|
||||
* and triggers node recreation by touching the store node.
|
||||
*/
|
||||
export function tryAsyncIconFontResolution(nodeId: string, iconName: string): void {
|
||||
const normalized = iconName.replace(/[-_\s/]+/g, '').replace(/icon$/i, '').toLowerCase()
|
||||
if (!normalized || pendingIconResolutions.has(nodeId)) return
|
||||
pendingIconResolutions.set(nodeId, normalized)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), 1500)
|
||||
|
||||
fetch(`/api/ai/icon?name=${encodeURIComponent(normalized)}`, { signal: controller.signal })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
clearTimeout(timer)
|
||||
if (!pendingIconResolutions.has(nodeId)) return
|
||||
pendingIconResolutions.delete(nodeId)
|
||||
|
||||
const icon = data?.icon as {
|
||||
d: string; style: 'stroke' | 'fill'; iconId?: string
|
||||
} | null
|
||||
if (!icon) return
|
||||
|
||||
// Cache in ICON_PATH_MAP so future lookups resolve instantly
|
||||
const entry: IconEntry = { d: icon.d, style: icon.style, iconId: icon.iconId ?? `resolved:${normalized}` }
|
||||
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = entry
|
||||
|
||||
// Touch the node in store to trigger canvas recreation
|
||||
const { getNodeById, updateNode } = useDocumentStore.getState()
|
||||
const node = getNodeById(nodeId)
|
||||
if (!node || node.type !== 'icon_font') return
|
||||
// Update iconFontName to the resolved short name (strip "lucide:" / "feather:" prefix)
|
||||
// to trigger __needsRecreation and ensure lookupIconByName resolves on next render.
|
||||
const resolvedName = (icon.iconId ?? normalized).replace(/^[a-z]+:/, '')
|
||||
updateNode(nodeId, { iconFontName: resolvedName } as Partial<PenNode>)
|
||||
})
|
||||
.catch(() => { clearTimeout(timer); pendingIconResolutions.delete(nodeId) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve icon path nodes by their name. When the AI generates a path node
|
||||
* with a name like "SearchIcon" or "MenuIcon", look up the verified SVG path
|
||||
|
|
@ -689,6 +826,7 @@ export function lookupIconByName(
|
|||
name: string,
|
||||
): { d: string; iconId: string; style: 'stroke' | 'fill' } | null {
|
||||
const normalized = name
|
||||
.replace(/^[a-z]+:/i, '') // strip icon set prefix (lucide:, feather:, resolved:)
|
||||
.replace(/[-_\s/]+/g, '')
|
||||
.replace(/icon$/i, '')
|
||||
.toLowerCase()
|
||||
|
|
|
|||
|
|
@ -74,12 +74,13 @@ RULES:
|
|||
- clipContent: true on cards with cornerRadius + image children.
|
||||
- Text: NEVER set height. Short text (titles, labels, buttons) — omit textGrowth. Long text (>15 chars wrapping) — textGrowth="fixed-width", width="fill_container", lineHeight=1.4-1.6.
|
||||
- lineHeight: Display 40-56px → 0.9-1.0. Heading 20-36px → 1.0-1.2. Body → 1.4-1.6. letterSpacing: -0.5 to -1 for headlines, 1-3 for uppercase.
|
||||
- Icons: icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px.
|
||||
- Icons: ALWAYS use icon_font nodes with iconFontName (lucide names: search, bell, user, heart, star, plus, x, check, chevron-right, settings, etc). Sizes: 14/20/24px. NEVER use emoji characters (🍕🍔⭐✅🔔 etc) as icon substitutes — they cannot render on canvas.
|
||||
- CJK fonts: "Noto Sans SC"/"Noto Sans JP"/"Noto Sans KR" for headings. CJK lineHeight: 1.3-1.4 headings, 1.6-1.8 body.
|
||||
- Buttons: frame(padding=[12,24], justifyContent="center") > text. Icon+text: frame(layout="horizontal", gap=8, alignItems="center", padding=[8,16]).
|
||||
- Card rows: ALL cards width="fill_container" + height="fill_container".
|
||||
- FORMS: ALL inputs AND button use width="fill_container". gap=16-20.
|
||||
- Phone mockup: ONE frame, w=260-300, h=520-580, cornerRadius=32, solid fill + 1px stroke.
|
||||
- Z-order: Earlier siblings render on top. Overlay elements (badges, indicators, floating buttons) MUST come BEFORE the content they overlap.
|
||||
|
||||
FORMAT: _parent (null=root, else parent-id). Parent before children.
|
||||
${BLOCK}json
|
||||
|
|
|
|||
|
|
@ -210,7 +210,8 @@ export async function executeOrchestration(
|
|||
width: plan.rootFrame.width,
|
||||
height: frameHeight,
|
||||
layout: plan.rootFrame.layout ?? 'vertical',
|
||||
gap: plan.rootFrame.gap ?? 0,
|
||||
gap: isMobile ? (plan.rootFrame.gap || 16) : (plan.rootFrame.gap ?? 16),
|
||||
...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}),
|
||||
fill: defaultFill,
|
||||
children: [],
|
||||
}
|
||||
|
|
@ -255,7 +256,8 @@ export async function executeOrchestration(
|
|||
width: plan.rootFrame.width,
|
||||
height: initialHeight,
|
||||
layout: plan.rootFrame.layout ?? 'vertical',
|
||||
gap: plan.rootFrame.gap ?? 0,
|
||||
gap: isMobile ? (plan.rootFrame.gap || 16) : (plan.rootFrame.gap ?? 16),
|
||||
...(plan.rootFrame.padding != null ? { padding: plan.rootFrame.padding } : {}),
|
||||
fill: defaultFill,
|
||||
children: [],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue