feat(panels): add icon section with library picker and in-place path replacement

- Add IconSection panel shown when a path node with iconId is selected
- Add IconPickerDialog: anchored popover with collection browsing, search,
  current icon highlight, and outside-click/Escape to close
- Add iconId field to PathNode to track Iconify icon identity
- Fix canvas path sync to update d in-place (no object recreation/focus loss)
- Fix multi-element SVG icons: convert circles, rects, lines to path commands
  so shapes like emoji face circles are not dropped during icon replacement
- Filter invisible bounding-box paths (Tabler's M0 0h24v24H0z) before merging
- Preserve original node width/height on icon replace; carry over display color
- Populate iconId in AI icon resolver and server-side Iconify proxy
This commit is contained in:
Fini 2026-02-24 00:25:49 +08:00
parent cd32399890
commit 0c456fe4d2
9 changed files with 521 additions and 157 deletions

View file

@ -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<IconResult | null> {
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 {

View file

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

View file

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

View file

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

View file

@ -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<PathNode>) => void
}
/**
* Convert any parsed SVG node type into an SVG path `d` string.
* SVGs can contain <circle>, <ellipse>, <rect>, <line>, <path>, 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<string | undefined>(undefined)
const [pickerPosition, setPickerPosition] = useState<IconPickerPosition | undefined>(undefined)
// Local display state for the library select — updates immediately on change
const [displayCollection, setDisplayCollection] = useState<string>('')
const triggerRef = useRef<HTMLButtonElement>(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 <circle> + <path> 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 <path stroke="none" d="M0 0h24v24H0z" fill="none"/>)
// 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 (
<div className="space-y-1.5">
<SectionHeader title="Icon" />
{/* Icon name row — opens full picker */}
<button
ref={triggerRef}
type="button"
onClick={() => openPicker('')}
className="w-full h-7 bg-secondary text-foreground text-[11px] px-2 rounded border border-transparent hover:border-input flex items-center justify-between cursor-pointer transition-colors"
>
<span className="truncate">{iconName}</span>
<ChevronDown size={11} className="text-muted-foreground shrink-0 ml-1" />
</button>
{/* Library row — native select for quick switching */}
<div className="relative">
<select
value={displayCollection}
onChange={(e) => handleLibraryChange(e.target.value)}
className="w-full h-7 bg-secondary text-foreground text-[11px] px-2 rounded border border-transparent hover:border-input focus:border-ring focus:outline-none cursor-pointer transition-colors appearance-none"
>
{!POPULAR_COLLECTIONS.some((c) => c.id === displayCollection) && displayCollection && (
<option value={displayCollection}>{displayCollection}</option>
)}
{POPULAR_COLLECTIONS.map((col) => (
<option key={col.id} value={col.id}>
{col.name}
</option>
))}
</select>
<ChevronDown
size={11}
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
</div>
<IconPickerDialog
open={pickerOpen}
onClose={handlePickerClose}
onSelect={handleIconSelect}
initialQuery={pickerInitialQuery}
collectionFilter={pickerCollection}
currentIconId={iconId}
position={pickerPosition}
/>
</div>
)
}

View file

@ -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 && (
<>
<Separator />
<div className="px-3 py-2">
<IconSection
node={displayNode as PathNode}
onUpdate={(updates) => handleUpdate(updates as Partial<PenNode>)}
/>
</div>
</>
)}
<Separator />
<div className="px-3 py-2">

View file

@ -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<string[]>([])
const [loading, setLoading] = useState(false)
const [fetching, setFetching] = useState<string | null>(null)
const [allIcons, setAllIcons] = useState<string[]>([])
const [totalCount, setTotalCount] = useState<number | null>(null)
const [collectionLoading, setCollectionLoading] = useState(false)
// Global search (no collection context)
const [searchIcons, setSearchIcons] = useState<string[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [fetching, setFetching] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | 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<string, string[]> (e.g. mdi)
// - icons: Record<string, object> (legacy / some collections)
const names = new Set<string>()
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<string, string[]>)) {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80"
onClick={onClose}
/>
// Determine icons to display
const trimmedQuery = query.trim().toLowerCase()
let displayIcons: string[]
let isLoading: boolean
{/* Dialog */}
<div className="relative bg-card rounded-lg border border-border p-4 w-[340px] shadow-xl">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground">Icons</h3>
<Button
variant="ghost"
size="icon-sm"
onClick={onClose}
>
<X size={14} />
</Button>
</div>
if (activeCollection) {
displayIcons = trimmedQuery
? allIcons.filter((id) => (id.split(':')[1] ?? '').includes(trimmedQuery))
: allIcons
isLoading = collectionLoading
} else {
displayIcons = searchIcons
isLoading = searchLoading
}
{/* Search input */}
<div className="relative mb-3">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
const countLabel = activeCollection
? trimmedQuery
? `${displayIcons.length} icons`
: `${totalCount ?? displayIcons.length} icons`
: null
{/* Results */}
<div className="max-h-[360px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : icons.length > 0 ? (
<div className="grid grid-cols-6 gap-1">
{icons.map((iconId) => {
const { collection, name } = parseIconId(iconId)
const isFetching = fetching === iconId
return (
<button
key={iconId}
title={iconId}
onClick={() => handleSelect(iconId)}
disabled={isFetching}
className="w-10 h-10 flex items-center justify-center rounded hover:bg-accent cursor-pointer transition-colors disabled:opacity-50"
>
{isFetching ? (
<Loader2 size={16} className="animate-spin text-muted-foreground" />
) : (
<img
src={`${ICONIFY_API}/${collection}/${name}.svg?height=20&color=${getIconColor()}`}
alt={iconId}
width={20}
height={20}
loading="lazy"
/>
)}
</button>
)
})}
</div>
) : searched ? (
<p className="text-xs text-muted-foreground text-center py-10">
No icons found
</p>
) : (
<p className="text-xs text-muted-foreground text-center py-10">
Type to search Iconify icons
</p>
)}
</div>
// 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 */}
<p className="text-[10px] text-muted-foreground mt-2 text-right">
Powered by Iconify
</p>
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(
<div
ref={containerRef}
style={panelStyle}
className="bg-card border border-border rounded-lg shadow-xl"
>
{/* Search */}
<div className="relative p-2 border-b border-border">
<Search
size={13}
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</div>
</div>
{/* Count */}
{!isLoading && countLabel && (
<p className="text-[11px] text-muted-foreground px-3 py-1.5">{countLabel}</p>
)}
{/* Grid */}
<div className="overflow-y-auto p-2" style={{ maxHeight: 380 }}>
{isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={18} className="animate-spin text-muted-foreground" />
</div>
) : displayIcons.length > 0 ? (
<div className="grid grid-cols-6 gap-0.5">
{displayIcons.map((iconId) => {
const { collection, name } = parseIconId(iconId)
const isFetching = fetching === iconId
const isCurrent = iconId === currentIconId
return (
<button
key={iconId}
title={name}
onClick={() => handleSelect(iconId)}
disabled={isFetching}
className={`w-10 h-10 flex items-center justify-center rounded transition-colors disabled:opacity-50 ${
isCurrent
? 'bg-primary/15 ring-1 ring-inset ring-primary'
: 'hover:bg-accent cursor-pointer'
}`}
>
{isFetching ? (
<Loader2 size={14} className="animate-spin text-muted-foreground" />
) : (
<img
src={`${ICONIFY_API}/${collection}/${name}.svg?height=18&color=${getIconColor()}`}
alt={name}
width={18}
height={18}
loading="lazy"
/>
)}
</button>
)
})}
</div>
) : searched && !activeCollection ? (
<p className="text-xs text-muted-foreground text-center py-8">No icons found</p>
) : !activeCollection && !trimmedQuery ? (
<p className="text-xs text-muted-foreground text-center py-8">
Type to search Iconify icons
</p>
) : null}
</div>
</div>,
document.body,
)
}

View file

@ -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<string, { d: string; style: 'stroke' | 'fill' }> = {
const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill'; iconId?: string }> = {
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<string, { d: string; style: 'stroke' | 'fill' }> = {
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<void> {
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<void> {
// Build update payload with resolved path + correct styling
const update: Partial<PenNode> = { d: icon.d }
if (icon.iconId) (update as Partial<PathNode>).iconId = icon.iconId
const existingColor = extractPrimaryColor('fill' in node ? node.fill : undefined)
?? extractPrimaryColor(node.stroke?.fill)
?? '#64748B'

View file

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