mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
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:
parent
f1ad0dcae2
commit
862881c330
3 changed files with 98 additions and 3 deletions
5
bun.lock
5
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue