mateclaw/mateclaw-ui/src/composables/usePixelarticons.ts
2026-05-05 20:09:58 +08:00

94 lines
3.4 KiB
TypeScript

/**
* pixelarticons (npm `pixelarticons`) — SVG icon loader.
*
* The package ships ~400 icons each with a "sharp" twin variant (~800
* SVGs total). We surface the regular set only — the sharp variants are
* a stylistic alternative that doubles bundle size for marginal value
* in our context. Each SVG is ~150B, so the regular set lands around
* 60KB raw / ~15KB gzipped — small enough to bundle eagerly so the
* picker can search/grid without lazy-loading round trips.
*
* Icon scheme: we encode a chosen pixelart icon as the string `pi:name`
* (kebab-case). The {@link SkillIcon} renderer detects this prefix and
* inlines the SVG at render time.
*/
// Eager glob into node_modules — Vite resolves `?raw` text imports for
// SVGs the same as any other asset. We exclude the `*-sharp.svg` files
// at the glob level so they never enter the bundle.
const modules = import.meta.glob<string>(
'../../node_modules/pixelarticons/svg/!(*-sharp).svg',
{ query: '?raw', import: 'default', eager: true },
)
/**
* Map from kebab-case icon name → raw SVG markup.
* Built once at module load; ~400 entries.
*/
export const pixelartIcons: Record<string, string> = (() => {
const out: Record<string, string> = {}
for (const [path, raw] of Object.entries(modules)) {
const name = path.split('/').pop()!.replace(/\.svg$/, '')
out[name] = raw
}
return out
})()
/**
* Sorted list of icon names — drives the picker grid. Cached because the
* picker reads it on every keystroke (search filter).
*/
export const pixelartIconNames: string[] = Object.keys(pixelartIcons).sort()
/**
* Encode a pixelart icon name into the persisted icon string.
* Symmetric with {@link parseIconValue}.
*/
export function encodePixelartIcon(name: string): string {
return `pi:${name}`
}
export type ParsedIcon =
| { kind: 'pixelart'; name: string; svg: string | null }
| { kind: 'url'; url: string }
| { kind: 'emoji'; value: string }
| { kind: 'empty' }
/**
* Plain-text icon glyph for hosts that can't render components — chiefly
* `<select><option>`. Returns the emoji as-is, falls back for `pi:` /
* URL icons (which would otherwise render as literal `pi:robot` text).
*/
export function plainTextIcon(value: string | null | undefined, fallback = '🤖'): string {
const parsed = parseIconValue(value)
return parsed.kind === 'emoji' ? parsed.value : fallback
}
/**
* Decode a stored icon string into a renderable shape. Returns
* {@code kind: 'empty'} for null/blank so callers can branch on a
* single discriminator.
*/
export function parseIconValue(value: string | null | undefined): ParsedIcon {
if (!value || !value.trim()) return { kind: 'empty' }
const v = value.trim()
if (v.startsWith('pi:')) {
const name = v.slice(3)
return { kind: 'pixelart', name, svg: pixelartIcons[name] ?? null }
}
if (v.startsWith('http://') || v.startsWith('https://')) {
return { kind: 'url', url: v }
}
// Site-absolute path (e.g. /skill-assets/<skill>/assets/<file>.png served
// by the WebMvcConfig resource handler) — treat as a URL the browser will
// resolve against the current host, so deployments don't need to hardcode
// their public origin in SKILL.md.
if (v.startsWith('/')) {
return { kind: 'url', url: v }
}
// Anything else falls through as an emoji / text glyph. We don't
// validate "is this really an emoji" — letting the user pick any
// unicode glyph is intentionally flexible.
return { kind: 'emoji', value: v }
}