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:
parent
cd32399890
commit
0c456fe4d2
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
229
src/components/panels/icon-section.tsx
Normal file
229
src/components/panels/icon-section.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue