feat(ai): bundle all 286 Feather icons for instant offline icon resolution

Install @iconify-json/feather and populate ICON_PATH_MAP at module init time
by parsing the bundled SVG body strings (handles <path>, <circle>, <ellipse>,
<rect> elements). Both kebab-case ("arrow-right") and normalized ("arrowright")
keys are registered so the icon name resolver finds them immediately without
any Iconify API calls.
This commit is contained in:
Fini 2026-02-24 01:18:46 +08:00
parent f1ad0dcae2
commit 862881c330
3 changed files with 98 additions and 3 deletions

View file

@ -7,6 +7,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@iconify-json/feather": "^1.2.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.2.6",
"@radix-ui/react-select": "^2.2.6",
@ -212,6 +213,10 @@
"@hono/node-server": ["@hono/node-server@1.19.9", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.9.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@iconify-json/feather": ["@iconify-json/feather@1.2.1", "https://registry.npmmirror.com/@iconify-json/feather/-/feather-1.2.1.tgz", { "dependencies": { "@iconify/types": "*" } }, "sha512-gURNg2TJYuO1U7DoOGCylm9TwkMfzjOH2BHdWsE0IXLXj/MNkFIJu56Wu1xRws27M8hzDzUDt/biGUa/LfAjdg=="],
"@iconify/types": ["@iconify/types@2.0.0", "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],

View file

@ -24,9 +24,10 @@
"electron:build": "BUILD_TARGET=electron bun --bun run build && bun run electron:compile && bun run mcp:compile && npx electron-builder --config electron-builder.yml"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.47",
"@anthropic-ai/sdk": "^0.77.0",
"@iconify-json/feather": "^1.2.1",
"@modelcontextprotocol/sdk": "^1.12.1",
"@opencode-ai/sdk": "1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",

View file

@ -1,5 +1,6 @@
import type { PenNode, PathNode } from '@/types/pen'
import { useDocumentStore } from '@/stores/document-store'
import featherData from '@iconify-json/feather/icons.json'
import {
clamp,
toSizeNumber,
@ -9,8 +10,8 @@ import {
// ---------------------------------------------------------------------------
// Core UI icon paths (Lucide-style, 24×24 viewBox)
// Only ~30 high-frequency icons for instant sync resolution during streaming.
// All other icons are resolved asynchronously via the Iconify API proxy.
// Hand-picked high-frequency icons for guaranteed instant sync resolution.
// Feather icons are added at module init from the bundled @iconify-json/feather.
// ---------------------------------------------------------------------------
const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill'; iconId?: string }> = {
// Navigation & actions
@ -118,6 +119,94 @@ const ICON_PATH_MAP: Record<string, { d: string; style: 'stroke' | 'fill'; iconI
circlefill: { d: 'M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0', style: 'fill', iconId: 'lucide:circle' },
}
// ---------------------------------------------------------------------------
// Feather icon set — bundled from @iconify-json/feather (286 icons, all stroke)
// Populated at module init so AI-generated designs never need async network fetches
// for standard Feather icons.
// ---------------------------------------------------------------------------
/**
* Parse a Feather SVG body string into a compound SVG path `d` string.
* Feather uses <path>, <circle>, <rect>, <ellipse> (all inside optional <g>).
* All elements are converted to equivalent path commands and joined.
*/
function featherBodyToPathD(body: string): string | null {
const parts: string[] = []
// <path d="...">
const pathRe = /\bd="([^"]+)"/g
let m: RegExpExecArray | null
while ((m = pathRe.exec(body)) !== null) parts.push(m[1])
// <circle cx="x" cy="y" r="r"> → two half-arcs forming a closed circle
const circleRe = /<circle[^>]+>/g
while ((m = circleRe.exec(body)) !== null) {
const tag = m[0]
const cx = parseFloat(tag.match(/\bcx="([^"]+)"/)?.[1] ?? 'NaN')
const cy = parseFloat(tag.match(/\bcy="([^"]+)"/)?.[1] ?? 'NaN')
const r = parseFloat(tag.match(/\br="([^"]+)"/)?.[1] ?? 'NaN')
if (!isNaN(cx) && !isNaN(cy) && !isNaN(r)) {
parts.push(`M ${cx - r} ${cy} a ${r} ${r} 0 1 0 ${r * 2} 0 a ${r} ${r} 0 1 0 ${-r * 2} 0 Z`)
}
}
// <ellipse cx="x" cy="y" rx="rx" ry="ry">
const ellipseRe = /<ellipse[^>]+>/g
while ((m = ellipseRe.exec(body)) !== null) {
const tag = m[0]
const cx = parseFloat(tag.match(/\bcx="([^"]+)"/)?.[1] ?? 'NaN')
const cy = parseFloat(tag.match(/\bcy="([^"]+)"/)?.[1] ?? 'NaN')
const rx = parseFloat(tag.match(/\brx="([^"]+)"/)?.[1] ?? 'NaN')
const ry = parseFloat(tag.match(/\bry="([^"]+)"/)?.[1] ?? 'NaN')
if (!isNaN(cx) && !isNaN(cy) && !isNaN(rx) && !isNaN(ry)) {
parts.push(`M ${cx - rx} ${cy} a ${rx} ${ry} 0 1 0 ${rx * 2} 0 a ${rx} ${ry} 0 1 0 ${-rx * 2} 0 Z`)
}
}
// <rect x="x" y="y" width="w" height="h" rx="r">
const rectRe = /<rect[^>]+>/g
while ((m = rectRe.exec(body)) !== null) {
const tag = m[0]
const x = parseFloat(tag.match(/\bx="([^"]+)"/)?.[1] ?? '0') || 0
const y = parseFloat(tag.match(/\by="([^"]+)"/)?.[1] ?? '0') || 0
const w = parseFloat(tag.match(/\bwidth="([^"]+)"/)?.[1] ?? 'NaN')
const h = parseFloat(tag.match(/\bheight="([^"]+)"/)?.[1] ?? 'NaN')
if (!isNaN(w) && !isNaN(h)) {
const rx = parseFloat(tag.match(/\brx="([^"]+)"/)?.[1] ?? '0') || 0
if (rx > 0) {
parts.push(
`M ${x + rx} ${y} L ${x + w - rx} ${y} Q ${x + w} ${y} ${x + w} ${y + rx}` +
` L ${x + w} ${y + h - rx} Q ${x + w} ${y + h} ${x + w - rx} ${y + h}` +
` L ${x + rx} ${y + h} Q ${x} ${y + h} ${x} ${y + h - rx}` +
` L ${x} ${y + rx} Q ${x} ${y} ${x + rx} ${y} Z`,
)
} else {
parts.push(`M ${x} ${y} L ${x + w} ${y} L ${x + w} ${y + h} L ${x} ${y + h} Z`)
}
}
}
return parts.length > 0 ? parts.join(' ') : null
}
// Populate ICON_PATH_MAP with all 286 Feather icons at module load time.
// Keys are stored both in original kebab-case and normalized (no separator)
// form to match the icon resolver's name normalization.
;(function initFeatherIcons() {
const icons = (featherData as { icons: Record<string, { body: string }> }).icons
for (const [name, icon] of Object.entries(icons)) {
const d = featherBodyToPathD(icon.body)
if (!d) continue
const iconId = `feather:${name}`
const entry = { d, style: 'stroke' as const, iconId }
// kebab-case key (e.g. "arrow-right") — for direct lookup in icon picker
if (!ICON_PATH_MAP[name]) ICON_PATH_MAP[name] = entry
// normalized key (e.g. "arrowright") — matches applyIconPathResolution normalization
const normalized = name.replace(/-/g, '')
if (!ICON_PATH_MAP[normalized]) ICON_PATH_MAP[normalized] = entry
}
})()
// ---------------------------------------------------------------------------
// Pending async icon resolution tracking
// ---------------------------------------------------------------------------