diff --git a/server/api/ai/icon.ts b/server/api/ai/icon.ts index d7ff1d9a2..867985a87 100644 --- a/server/api/ai/icon.ts +++ b/server/api/ai/icon.ts @@ -5,6 +5,7 @@ interface IconResult { style: 'stroke' | 'fill' width: number height: number + iconId?: string // Iconify icon ID, e.g. "lucide:home" } // In-memory cache: icon name → result (null = confirmed miss) @@ -79,7 +80,9 @@ async function tryDirectLookup( if (!res.ok) return null const data = (await res.json()) as { body?: string; width?: number; height?: number } if (!data.body) return null - return parseIconBody(data.body, data.width ?? 24, data.height ?? 24) + const result = parseIconBody(data.body, data.width ?? 24, data.height ?? 24) + if (result) result.iconId = `${collection}:${iconName}` + return result } catch { return null } @@ -98,7 +101,10 @@ async function trySearchApi(query: string): Promise { const [collection, iconName] = fullName.split(':') if (!collection || !iconName) continue const result = await tryDirectLookup(collection, iconName) - if (result) return result + if (result) { + result.iconId = fullName + return result + } } return null } catch { diff --git a/src/canvas/canvas-object-sync.ts b/src/canvas/canvas-object-sync.ts index 59eb3c407..565f16a74 100644 --- a/src/canvas/canvas-object-sync.ts +++ b/src/canvas/canvas-object-sync.ts @@ -145,6 +145,24 @@ export function syncFabricObject( } 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 diff --git a/src/canvas/use-canvas-sync.ts b/src/canvas/use-canvas-sync.ts index 042bc2cf0..7c0e0bdbc 100644 --- a/src/canvas/use-canvas-sync.ts +++ b/src/canvas/use-canvas-sync.ts @@ -428,7 +428,8 @@ export function useCanvasSync() { 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). + // class/shape data changes (e.g. IText↔Textbox). + // Path `d` changes are handled in-place by syncFabricObject. let objectRecreated = false // Text nodes may need recreation when textGrowth mode changes // (IText ↔ Textbox are different Fabric classes). @@ -444,22 +445,6 @@ export function useCanvasSync() { } } - // 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 - } - } - if (existingObj) { // Skip objects inside an ActiveSelection — their left/top are // group-relative, not absolute. Setting absolute values from diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index 694e6ab04..8c88170fe 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -52,6 +52,7 @@ export default function Toolbar() { node.x = centerX - w / 2 node.y = centerY - h / 2 node.name = iconName + if (node.type === 'path') node.iconId = iconName useDocumentStore.getState().addNode(null, node) } setIconPickerOpen(false) diff --git a/src/components/panels/icon-section.tsx b/src/components/panels/icon-section.tsx new file mode 100644 index 000000000..81307c4d0 --- /dev/null +++ b/src/components/panels/icon-section.tsx @@ -0,0 +1,229 @@ +import { useState, useRef, useEffect } from 'react' +import { ChevronDown } from 'lucide-react' +import type { PenNode, PathNode } from '@/types/pen' +import type { PenFill, PenStroke } from '@/types/styles' +import SectionHeader from '@/components/shared/section-header' +import IconPickerDialog, { type IconPickerPosition } from '@/components/shared/icon-picker-dialog' +import { parseSvgToNodes } from '@/utils/svg-parser' + +const POPULAR_COLLECTIONS = [ + { id: 'feather', name: 'Feather' }, + { id: 'lucide', name: 'Lucide' }, + { id: 'mdi', name: 'Material' }, + { id: 'ph', name: 'Phosphor' }, + { id: 'ri', name: 'Remix' }, + { id: 'tabler', name: 'Tabler' }, + { id: 'heroicons', name: 'Heroicons' }, + { id: 'fa6-solid', name: 'Font Awesome' }, + { id: 'simple-icons', name: 'Brand Icons' }, +] + +interface IconSectionProps { + node: PathNode + onUpdate: (updates: Partial) => void +} + +/** + * Convert any parsed SVG node type into an SVG path `d` string. + * SVGs can contain , , , , , etc. + * We convert them all to path commands so they can be merged into one compound path. + */ +function nodeToPathD(node: PenNode): string | null { + if (node.type === 'path') { + return typeof (node as PathNode).d === 'string' ? (node as PathNode).d : null + } + if (node.type === 'ellipse') { + // Center + radii from the node's bounding box position + const rx = typeof node.width === 'number' ? node.width / 2 : 12 + const ry = typeof node.height === 'number' ? node.height / 2 : 12 + const cx = (node.x ?? 0) + rx + const cy = (node.y ?? 0) + ry + // Two half-arc commands form a closed ellipse/circle + return `M ${cx - rx} ${cy} a ${rx} ${ry} 0 1 0 ${rx * 2} 0 a ${rx} ${ry} 0 1 0 ${-rx * 2} 0 Z` + } + if (node.type === 'rectangle') { + const x = node.x ?? 0 + const y = node.y ?? 0 + const w = typeof node.width === 'number' ? node.width : 24 + const h = typeof node.height === 'number' ? node.height : 24 + return `M ${x} ${y} L ${x + w} ${y} L ${x + w} ${y + h} L ${x} ${y + h} Z` + } + if (node.type === 'line') { + return `M ${node.x ?? 0} ${node.y ?? 0} L ${node.x2 ?? 0} ${node.y2 ?? 0}` + } + return null +} + +export default function IconSection({ node, onUpdate }: IconSectionProps) { + const [pickerOpen, setPickerOpen] = useState(false) + const [pickerInitialQuery, setPickerInitialQuery] = useState('') + const [pickerCollection, setPickerCollection] = useState(undefined) + const [pickerPosition, setPickerPosition] = useState(undefined) + // Local display state for the library select — updates immediately on change + const [displayCollection, setDisplayCollection] = useState('') + + const triggerRef = useRef(null) + + const iconId = node.iconId! + const colonIdx = iconId.indexOf(':') + const collection = colonIdx >= 0 ? iconId.slice(0, colonIdx) : '' + const iconName = colonIdx >= 0 ? iconId.slice(colonIdx + 1) : iconId + + // Sync displayCollection when the node changes (e.g. after icon replacement) + useEffect(() => { + setDisplayCollection(collection) + }, [collection]) + + const openPicker = (query: string, collectionFilter?: string) => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect() + setPickerPosition({ top: rect.top, right: 0 }) + } + setPickerInitialQuery(query) + setPickerCollection(collectionFilter) + setPickerOpen(true) + } + + /** + * Parse the selected SVG and update the icon node. + * + * Multi-element SVGs (e.g. face icons with + elements) are + * merged into a single compound path `d` string. Non-path elements (circles, + * rects, lines) are converted to equivalent SVG path commands so no shapes + * are lost. Invisible bounding-box placeholders (stroke=none, fill=none) are + * skipped. Width/height are preserved so the icon stays in its layout slot. + */ + const handleIconSelect = (svgText: string, newIconId: string) => { + const nodes = parseSvgToNodes(svgText) + if (nodes.length === 0) return + + const first = nodes[0] + let d: string + let fill: PenFill[] | undefined = node.fill + let stroke: PenStroke | undefined = node.stroke + + if (first.type === 'path') { + d = first.d + fill = first.fill + stroke = first.stroke + } else if (first.type === 'frame' && first.children && first.children.length > 0) { + // Collect path `d` from ALL child element types (paths, circles, rects, lines). + // This preserves every shape in the icon — e.g. the circle in a face emoji. + interface Part { d: string; fill: PenFill[] | undefined; stroke: PenStroke | undefined } + const parts: Part[] = [] + + for (const child of first.children) { + const childFill = ('fill' in child ? (child as PathNode).fill : undefined) as PenFill[] | undefined + const childStroke = ('stroke' in child ? (child as PathNode).stroke : undefined) as PenStroke | undefined + + // Skip invisible bounding-box paths only (e.g. Tabler's ) + // Non-path shapes (ellipse, rect, line) are never bounding boxes — always include them. + if (child.type === 'path') { + const isInvisible = + childStroke === undefined && + (!childFill || childFill.every((f) => f.type === 'solid' && f.color === 'transparent')) + if (isInvisible) continue + } + + const pathD = nodeToPathD(child) + if (pathD) parts.push({ d: pathD, fill: childFill, stroke: childStroke }) + } + + if (parts.length === 0) return + d = parts.map((p) => p.d).join(' ') + fill = parts[0].fill + stroke = parts[0].stroke + } else { + return + } + + // Carry over the old icon's display color to the new icon. + // Check stroke first (stroke-based icons like Feather/Lucide), then fill. + const oldColor = (() => { + if (node.stroke?.fill?.[0]?.type === 'solid') { + const c = node.stroke.fill[0].color + if (c !== 'transparent') return c + } + if (node.fill?.[0]?.type === 'solid') { + const c = node.fill[0].color + if (c !== 'transparent') return c + } + return null + })() + + if (oldColor) { + if (stroke !== undefined) { + stroke = { ...stroke, fill: [{ type: 'solid', color: oldColor }] } + } + if (fill?.some((f) => f.type === 'solid' && f.color !== 'transparent')) { + fill = fill.map((f) => + f.type === 'solid' && f.color !== 'transparent' ? { ...f, color: oldColor } : f, + ) + } + } + + // Preserve original width/height — canvas factory scales path to fit existing bounds + onUpdate({ d, fill, stroke, iconId: newIconId, name: newIconId }) + // Keep picker open so user can browse and try other icons + } + + const handleLibraryChange = (newCollection: string) => { + setDisplayCollection(newCollection) + openPicker('', newCollection) + } + + const handlePickerClose = () => { + // Reset display collection to actual node collection if user cancelled + setDisplayCollection(collection) + setPickerOpen(false) + } + + return ( +
+ + + {/* Icon name row — opens full picker */} + + + {/* Library row — native select for quick switching */} +
+ + +
+ + +
+ ) +} diff --git a/src/components/panels/property-panel.tsx b/src/components/panels/property-panel.tsx index f7b63120b..4a7148fe1 100644 --- a/src/components/panels/property-panel.tsx +++ b/src/components/panels/property-panel.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore } from '@/stores/document-store' import { Separator } from '@/components/ui/separator' -import type { PenNode, ContainerProps, RefNode } from '@/types/pen' +import type { PenNode, ContainerProps, RefNode, PathNode } from '@/types/pen' import { Component, Diamond, ArrowUpRight, Unlink } from 'lucide-react' import { Button } from '@/components/ui/button' import type { FabricObjectWithPenId } from '@/canvas/canvas-object-factory' @@ -15,6 +15,7 @@ import TextSection from './text-section' import TextLayoutSection from './text-layout-section' import EffectsSection from './effects-section' import ExportSection from './export-section' +import IconSection from './icon-section' /** Properties stored directly on the RefNode (instance-level), not as overrides. */ const INSTANCE_DIRECT_PROPS = new Set([ @@ -121,6 +122,7 @@ export default function PropertyPanel() { displayNode.type === 'rectangle' || displayNode.type === 'frame' const hasEffects = true const isText = displayNode.type === 'text' + const isIcon = displayNode.type === 'path' && !!(displayNode as PathNode).iconId const [isEditingName, setIsEditingName] = useState(false) const [editName, setEditName] = useState('') @@ -278,6 +280,18 @@ export default function PropertyPanel() { )} + {isIcon && ( + <> + +
+ handleUpdate(updates as Partial)} + /> +
+ + )} +
diff --git a/src/components/shared/icon-picker-dialog.tsx b/src/components/shared/icon-picker-dialog.tsx index 8eefc0f23..0041650c5 100644 --- a/src/components/shared/icon-picker-dialog.tsx +++ b/src/components/shared/icon-picker-dialog.tsx @@ -1,57 +1,119 @@ -import { useState, useEffect, useRef, useCallback } from 'react' -import { X, Loader2, Search } from 'lucide-react' -import { Button } from '@/components/ui/button' +import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, Search } from 'lucide-react' const ICONIFY_API = 'https://api.iconify.design' -const SEARCH_LIMIT = 64 -const DEBOUNCE_MS = 300 +const DEBOUNCE_MS = 250 -/** Detect dark mode and return a color param for Iconify preview URLs. */ function getIconColor(): string { const isLight = typeof document !== 'undefined' && document.documentElement.classList.contains('light') - // Encode # as %23 for URL usage return isLight ? '%23333333' : '%23e4e4e7' } +function parseIconId(id: string) { + const idx = id.indexOf(':') + return { collection: id.slice(0, idx), name: id.slice(idx + 1) } +} + +export interface IconPickerPosition { + top: number + right: number +} + interface IconPickerDialogProps { open: boolean onClose: () => void onSelect: (svgText: string, iconName: string) => void -} - -/** Split "mdi:home" → { collection: "mdi", name: "home" } */ -function parseIconId(id: string) { - const idx = id.indexOf(':') - return { collection: id.slice(0, idx), name: id.slice(idx + 1) } + /** Pre-fill the search query when opened */ + initialQuery?: string + /** Restrict results to this Iconify collection prefix */ + collectionFilter?: string + /** Highlight this icon ID as the current selection */ + currentIconId?: string + /** Position of the popover (anchored near trigger element) */ + position?: IconPickerPosition } export default function IconPickerDialog({ open, onClose, onSelect, + initialQuery, + collectionFilter, + currentIconId, + position, }: IconPickerDialogProps) { const [query, setQuery] = useState('') - const [icons, setIcons] = useState([]) - const [loading, setLoading] = useState(false) - const [fetching, setFetching] = useState(null) + const [allIcons, setAllIcons] = useState([]) + const [totalCount, setTotalCount] = useState(null) + const [collectionLoading, setCollectionLoading] = useState(false) + // Global search (no collection context) + const [searchIcons, setSearchIcons] = useState([]) + const [searchLoading, setSearchLoading] = useState(false) const [searched, setSearched] = useState(false) + const [fetching, setFetching] = useState(null) + + const containerRef = useRef(null) const inputRef = useRef(null) const timerRef = useRef | null>(null) - // Focus input on open + // The active collection: explicit filter > current icon's collection > default Feather + const activeCollection = + collectionFilter ?? + (currentIconId ? currentIconId.split(':')[0] : undefined) ?? + 'feather' + + // Load all icons for the active collection + useEffect(() => { + if (!activeCollection) return + setCollectionLoading(true) + setAllIcons([]) + setTotalCount(null) + + fetch(`${ICONIFY_API}/collection?prefix=${encodeURIComponent(activeCollection)}`) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return + // Iconify collection API stores names in multiple places: + // - uncategorized: string[] (most collections, e.g. lucide, ph) + // - categories: Record (e.g. mdi) + // - icons: Record (legacy / some collections) + const names = new Set() + if (Array.isArray(data.uncategorized)) { + for (const n of data.uncategorized) names.add(n) + } + if (data.categories && typeof data.categories === 'object') { + for (const arr of Object.values(data.categories as Record)) { + if (Array.isArray(arr)) for (const n of arr) names.add(n) + } + } + if (data.icons && typeof data.icons === 'object') { + for (const n of Object.keys(data.icons)) names.add(n) + } + const list = [...names] + setAllIcons(list.map((n) => `${activeCollection}:${n}`)) + setTotalCount(data.total ?? list.length) + }) + .catch(() => {}) + .finally(() => setCollectionLoading(false)) + }, [activeCollection]) + + // Focus + pre-fill on open useEffect(() => { if (open) { - setTimeout(() => inputRef.current?.focus(), 50) + setQuery(initialQuery ?? '') + setTimeout(() => inputRef.current?.focus(), 30) } else { setQuery('') - setIcons([]) + setSearchIcons([]) setSearched(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - // Escape key closes dialog + // Close on Escape useEffect(() => { if (!open) return const handler = (e: KeyboardEvent) => { @@ -64,38 +126,52 @@ export default function IconPickerDialog({ return () => document.removeEventListener('keydown', handler) }, [open, onClose]) - // Debounced search - const doSearch = useCallback((q: string) => { - if (timerRef.current) clearTimeout(timerRef.current) + // Close on outside click + useEffect(() => { + if (!open) return + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + onClose() + } + } + // Delay so the opening click doesn't immediately close it + const id = setTimeout(() => document.addEventListener('mousedown', handler), 100) + return () => { + clearTimeout(id) + document.removeEventListener('mousedown', handler) + } + }, [open, onClose]) + // Global search (used when no collection is loaded) + const doGlobalSearch = (q: string) => { + if (timerRef.current) clearTimeout(timerRef.current) if (!q.trim()) { - setIcons([]) - setLoading(false) + setSearchIcons([]) + setSearchLoading(false) setSearched(false) return } - - setLoading(true) + setSearchLoading(true) timerRef.current = setTimeout(async () => { try { const res = await fetch( - `${ICONIFY_API}/search?query=${encodeURIComponent(q.trim())}&limit=${SEARCH_LIMIT}`, + `${ICONIFY_API}/search?query=${encodeURIComponent(q.trim())}&limit=120`, ) - if (!res.ok) throw new Error('Search failed') + if (!res.ok) throw new Error() const data = await res.json() - setIcons(data.icons ?? []) + setSearchIcons(data.icons ?? []) } catch { - setIcons([]) + setSearchIcons([]) } finally { - setLoading(false) + setSearchLoading(false) setSearched(true) } }, DEBOUNCE_MS) - }, []) + } const handleQueryChange = (val: string) => { setQuery(val) - doSearch(val) + if (!activeCollection) doGlobalSearch(val) } const handleSelect = async (iconId: string) => { @@ -103,7 +179,7 @@ export default function IconPickerDialog({ setFetching(iconId) try { const res = await fetch(`${ICONIFY_API}/${collection}/${name}.svg`) - if (!res.ok) throw new Error('Fetch failed') + if (!res.ok) throw new Error() const svgText = await res.text() onSelect(svgText, iconId) } catch { @@ -113,96 +189,128 @@ export default function IconPickerDialog({ } } - if (!open) return null + if (!open || typeof document === 'undefined') return null - return ( -
- {/* Backdrop */} -
+ // Determine icons to display + const trimmedQuery = query.trim().toLowerCase() + let displayIcons: string[] + let isLoading: boolean - {/* Dialog */} -
- {/* Header */} -
-

Icons

- -
+ if (activeCollection) { + displayIcons = trimmedQuery + ? allIcons.filter((id) => (id.split(':')[1] ?? '').includes(trimmedQuery)) + : allIcons + isLoading = collectionLoading + } else { + displayIcons = searchIcons + isLoading = searchLoading + } - {/* Search input */} -
- - handleQueryChange(e.target.value)} - placeholder="Search icons..." - className="w-full bg-secondary border border-input rounded-md pl-8 pr-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring" - /> -
+ const countLabel = activeCollection + ? trimmedQuery + ? `${displayIcons.length} icons` + : `${totalCount ?? displayIcons.length} icons` + : null - {/* Results */} -
- {loading ? ( -
- -
- ) : icons.length > 0 ? ( -
- {icons.map((iconId) => { - const { collection, name } = parseIconId(iconId) - const isFetching = fetching === iconId - return ( - - ) - })} -
- ) : searched ? ( -

- No icons found -

- ) : ( -

- Type to search Iconify icons -

- )} -
+ // Compute popover position: anchored to the left of the property panel + const PANEL_WIDTH = 256 // property panel w-64 + const PICKER_WIDTH = 280 + const PICKER_MAX_HEIGHT = 480 + const MARGIN = 8 - {/* Footer */} -

- Powered by Iconify -

+ const top = position + ? Math.min( + Math.max(position.top, MARGIN), + window.innerHeight - PICKER_MAX_HEIGHT - MARGIN, + ) + : Math.round(window.innerHeight / 2 - PICKER_MAX_HEIGHT / 2) + + const right = PANEL_WIDTH + MARGIN + + const panelStyle: React.CSSProperties = { + position: 'fixed', + top, + right, + width: PICKER_WIDTH, + maxHeight: PICKER_MAX_HEIGHT, + zIndex: 9999, + } + + return createPortal( +
+ {/* Search */} +
+ + handleQueryChange(e.target.value)} + placeholder="Search icons..." + className="w-full bg-secondary border border-transparent rounded pl-7 pr-3 py-1.5 text-[12px] text-foreground placeholder:text-muted-foreground outline-none focus:border-ring" + />
-
+ + {/* Count */} + {!isLoading && countLabel && ( +

{countLabel}

+ )} + + {/* Grid */} +
+ {isLoading ? ( +
+ +
+ ) : displayIcons.length > 0 ? ( +
+ {displayIcons.map((iconId) => { + const { collection, name } = parseIconId(iconId) + const isFetching = fetching === iconId + const isCurrent = iconId === currentIconId + return ( + + ) + })} +
+ ) : searched && !activeCollection ? ( +

No icons found

+ ) : !activeCollection && !trimmedQuery ? ( +

+ Type to search Iconify icons +

+ ) : null} +
+
, + document.body, ) } diff --git a/src/services/ai/icon-resolver.ts b/src/services/ai/icon-resolver.ts index dcb5cc81c..4d9338884 100644 --- a/src/services/ai/icon-resolver.ts +++ b/src/services/ai/icon-resolver.ts @@ -12,28 +12,28 @@ import { // Only ~30 high-frequency icons for instant sync resolution during streaming. // All other icons are resolved asynchronously via the Iconify API proxy. // --------------------------------------------------------------------------- -const ICON_PATH_MAP: Record = { +const ICON_PATH_MAP: Record = { menu: { d: 'M4 6h16M4 12h16M4 18h16', style: 'stroke' }, x: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke' }, - close: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke' }, + close: { d: 'M18 6L6 18M6 6l12 12', style: 'stroke', iconId: 'lucide:x' }, check: { d: 'M20 6L9 17l-5-5', style: 'stroke' }, plus: { d: 'M12 5v14M5 12h14', style: 'stroke' }, minus: { d: 'M5 12h14', style: 'stroke' }, search: { d: 'M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.35-4.35', style: 'stroke' }, - arrowright: { d: 'M5 12h14M12 5l7 7-7 7', style: 'stroke' }, - arrowleft: { d: 'M19 12H5M12 19l-7-7 7-7', style: 'stroke' }, - arrowup: { d: 'M12 19V5M5 12l7-7 7 7', style: 'stroke' }, - arrowdown: { d: 'M12 5v14M19 12l-7 7-7-7', style: 'stroke' }, - chevronright: { d: 'M9 18l6-6-6-6', style: 'stroke' }, - chevronleft: { d: 'M15 18l-6-6 6-6', style: 'stroke' }, - chevrondown: { d: 'M6 9l6 6 6-6', style: 'stroke' }, - chevronup: { d: 'M18 15l-6-6-6 6', style: 'stroke' }, + arrowright: { d: 'M5 12h14M12 5l7 7-7 7', style: 'stroke', iconId: 'lucide:arrow-right' }, + arrowleft: { d: 'M19 12H5M12 19l-7-7 7-7', style: 'stroke', iconId: 'lucide:arrow-left' }, + arrowup: { d: 'M12 19V5M5 12l7-7 7 7', style: 'stroke', iconId: 'lucide:arrow-up' }, + arrowdown: { d: 'M12 5v14M19 12l-7 7-7-7', style: 'stroke', iconId: 'lucide:arrow-down' }, + chevronright: { d: 'M9 18l6-6-6-6', style: 'stroke', iconId: 'lucide:chevron-right' }, + chevronleft: { d: 'M15 18l-6-6 6-6', style: 'stroke', iconId: 'lucide:chevron-left' }, + chevrondown: { d: 'M6 9l6 6 6-6', style: 'stroke', iconId: 'lucide:chevron-down' }, + chevronup: { d: 'M18 15l-6-6-6 6', style: 'stroke', iconId: 'lucide:chevron-up' }, star: { d: 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z', style: 'fill' }, heart: { d: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', style: 'stroke' }, home: { d: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9zM9 22V12h6v10', style: 'stroke' }, user: { d: 'M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M16 7a4 4 0 11-8 0 4 4 0 018 0z', style: 'stroke' }, settings: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' }, - gear: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' }, + gear: { d: 'M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke', iconId: 'lucide:settings' }, mail: { d: 'M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm16 2l-10 7L2 6', style: 'stroke' }, eye: { d: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8zM15 12a3 3 0 11-6 0 3 3 0 016 0z', style: 'stroke' }, lock: { d: 'M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4', style: 'stroke' }, @@ -45,10 +45,10 @@ const ICON_PATH_MAP: Record = { globe: { d: 'M12 22a10 10 0 100-20 10 10 0 000 20zM2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z', style: 'stroke' }, send: { d: 'M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z', style: 'stroke' }, code: { d: 'M16 18l6-6-6-6M8 6l-6 6 6 6', style: 'stroke' }, - dot: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' }, - bullet: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' }, - point: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill' }, - circlefill: { d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', style: 'fill' }, + dot: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' }, + bullet: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' }, + point: { d: 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0', style: 'fill', iconId: 'lucide:circle' }, + circlefill: { d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', style: 'fill', iconId: 'lucide:circle' }, } // --------------------------------------------------------------------------- @@ -86,8 +86,9 @@ export function applyIconPathResolution(node: PenNode): void { return } - // Replace with verified path data + // Replace with verified path data and mark as resolved icon node.d = match.d + node.iconId = match.iconId ?? `lucide:${rawName}` applyIconStyle(node, match.style) } @@ -151,7 +152,7 @@ export async function resolveAsyncIcons(rootNodeId: string): Promise { const res = await fetch(`/api/ai/icon?name=${encodeURIComponent(iconName)}`) if (!res.ok) return { nodeId, icon: null } const data = (await res.json()) as { - icon: { d: string; style: 'stroke' | 'fill'; width: number; height: number } | null + icon: { d: string; style: 'stroke' | 'fill'; width: number; height: number; iconId?: string } | null } return { nodeId, icon: data.icon } }), @@ -169,6 +170,7 @@ export async function resolveAsyncIcons(rootNodeId: string): Promise { // Build update payload with resolved path + correct styling const update: Partial = { d: icon.d } + if (icon.iconId) (update as Partial).iconId = icon.iconId const existingColor = extractPrimaryColor('fill' in node ? node.fill : undefined) ?? extractPrimaryColor(node.stroke?.fill) ?? '#64748B' diff --git a/src/types/pen.ts b/src/types/pen.ts index d3bf2f4a5..acb943d6c 100644 --- a/src/types/pen.ts +++ b/src/types/pen.ts @@ -126,6 +126,7 @@ export interface PolygonNode extends PenNodeBase { export interface PathNode extends PenNodeBase { type: 'path' + iconId?: string // Iconify icon ID, e.g. "lucide:home" d: string width?: SizingBehavior height?: SizingBehavior