mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 13:47:59 +08:00
### What this PR does Before this PR: - Provider/model icons were scattered image imports (PNG/WebP) with no unified API - Avatar primitive was based on HeroUI with hardcoded `shadow-lg` and `border-[0.5px]` - Full-bleed and padded avatar variants used different rendering approaches - Multiple files duplicated IIFE patterns for rendering CompoundIcon vs string logos - No type-safe icon catalogs After this PR: - **Compound Icon API**: Each icon exposes `.Color`, `.Mono`, and `.Avatar` sub-components via a unified `CompoundIcon` interface - **Auto-generated catalogs**: `PROVIDER_ICON_CATALOG` and `MODEL_ICON_CATALOG` with `resolveProviderIcon` / `resolveModelIcon` helpers - **SVG pipeline**: Codegen processes SVGs → generates Color/Mono/Avatar components - **Avatar migrated to shadcn/radix**: Replaced HeroUI Avatar with `Avatar` + `AvatarFallback` pattern, removed hardcoded shadow/border - **EmojiAvatar moved**: From `primitives/Avatar/` to `composites/EmojiAvatar/` - **LogoAvatar component**: Reusable component replacing repeated IIFE patterns across 5+ files - **getMCPProviderLogo helper**: Centralized MCP provider icon mapping - 80+ monochrome icon components, stroke attribute support, deprecated logos cleanup <img width="714" height="820" alt="image" src="https://github.com/user-attachments/assets/a3f14348-5781-494a-8c3b-1f40391e2ec0" /> <img width="1008" height="593" alt="image" src="https://github.com/user-attachments/assets/8ba7fa42-fa33-4e49-ba76-647ba1438e0c" /> ### Why we need it and why it was done in this way The v2 refactoring requires moving away from HeroUI toward shadcn/radix primitives, and needs a scalable, type-safe icon system to replace scattered image imports. The compound icon pattern (`Icon.Color`, `Icon.Mono`, `Icon.Avatar`) provides a consistent API while enabling tree-shaking. The Avatar primitive now uses radix-based `Avatar` + `AvatarFallback`, aligning with the project's shadcn migration. The following tradeoffs were made: - Each icon is a separate TSX file for tree-shaking and lazy loading support - Avatar components use `AvatarFallback` to render icons — no image loading overhead The following alternatives were considered: - Runtime SVG color manipulation — rejected for better performance and consistency - Keeping HeroUI Avatar — rejected as it conflicts with v2 shadcn migration goals ### Breaking changes - Avatar primitive API changed: `HeroUI Avatar` → shadcn `Avatar` + `AvatarFallback` + `AvatarImage` - `EmojiAvatar` moved from `primitives/Avatar` to `composites/EmojiAvatar` - `shadow-lg` and `border-[0.5px]` removed from generated avatars — now opt-in via `className` ### Special notes for your reviewer - ~214 files changed, but the bulk are auto-generated avatar/icon components under `packages/ui/src/components/icons/` - Key files to review: - `packages/ui/src/components/primitives/avatar.tsx` — new shadcn Avatar primitive - `packages/ui/scripts/codegen.ts` — avatar generation using AvatarFallback - `src/renderer/src/components/Icons/LogoAvatar.tsx` — reusable logo renderer - Renderer files using the new Avatar API (Sidebar, UserPopup, ModelAvatar, etc.) ### Checklist - [x] PR: The PR description is expressive enough and will help future contributors - [x] Code: Write code that humans can understand and Keep it simple - [x] Refactor: You have left the code cleaner than you found it (Boy Scout Rule) - [ ] Upgrade: Impact of this change on upgrade flows was considered and addressed if required - [ ] Documentation: N/A - internal component changes ### Release note ```release-note NONE ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: suyao <sy20010504@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: icarus <eurfelux@gmail.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* Custom svgo v3 plugin: removeBackground
|
|
*
|
|
* Detects and removes background shapes (rects, rounded-rect paths, large traced
|
|
* bitmaps) from the SVG AST. Captures the removed element's fill color so the
|
|
* caller can use it as `colorPrimary`.
|
|
*
|
|
* Usage:
|
|
* const bg = createRemoveBackgroundPlugin()
|
|
* // pass bg.plugin in svgoConfig.plugins
|
|
* // after transform: bg.getBackgroundFill()
|
|
*/
|
|
import {
|
|
colorToLuminance,
|
|
isLargeShape,
|
|
isNearWhiteFill,
|
|
isWhiteFill,
|
|
parseSvgPathBounds,
|
|
parseViewBox
|
|
} from './svg-utils'
|
|
import type { CustomPlugin, XastChild, XastElement, XastParent, XastRoot } from './types'
|
|
|
|
const { detachNodeFromParent } = require('svgo/lib/xast') as {
|
|
detachNodeFromParent: (node: XastChild, parentNode: XastParent) => void
|
|
}
|
|
|
|
/** Regex patterns for rounded-rect path commands ([\d.]+ to handle decimal coords like 4.99999). */
|
|
const ROUNDED_RECT_PATTERNS = [
|
|
/^M[\d.]+[\s,]+0H[\d.]+[CA]/, // M18 0H6C... or M19.503 0H4.496A... (cubic/arc rounded corners)
|
|
/^M0[\s,]+[\d.]+[CA]/, // M0 6C... or M0 6A...
|
|
/^M0[\s,]+0[HVhv]/, // M0 0H...
|
|
/^M[\d.]+[\s,]+[\d.]+H[\d.]+V[\d.]+H[\d.]+V[\d.]+Z?$/i // Simple rect
|
|
]
|
|
|
|
function isRoundedRectPath(d: string): boolean {
|
|
const trimmed = d.trim()
|
|
return ROUNDED_RECT_PATTERNS.some((p) => p.test(trimmed))
|
|
}
|
|
|
|
function pathCommandCount(d: string): number {
|
|
return (d.match(/[a-zA-Z]/g) || []).length
|
|
}
|
|
|
|
interface PathInfo {
|
|
node: XastElement
|
|
parent: XastElement
|
|
d: string
|
|
area: number
|
|
fill: string
|
|
resolvedFill: string // Resolved from gradient url(#id) to actual color
|
|
lum: number
|
|
cmdCount: number
|
|
isWhite: boolean
|
|
}
|
|
|
|
interface RemoveBackgroundOptions {
|
|
/** If true, only detect background fill without removing elements. Used for color.tsx generation. */
|
|
detectOnly?: boolean
|
|
}
|
|
|
|
export function createRemoveBackgroundPlugin(options: RemoveBackgroundOptions = {}) {
|
|
const { detectOnly = false } = options
|
|
let backgroundFill: string | null = null
|
|
let removed = false
|
|
|
|
const plugin = {
|
|
name: 'removeBackground',
|
|
fn: (root: XastRoot) => {
|
|
// Pre-scan: find <svg> element and extract viewBox
|
|
let svgNode: XastElement | null = null
|
|
for (const child of root.children) {
|
|
if (child.type === 'element' && child.name === 'svg') {
|
|
svgNode = child
|
|
break
|
|
}
|
|
}
|
|
if (!svgNode) return {}
|
|
|
|
const vb = parseViewBox(svgNode.attributes)
|
|
const vbArea = vb.w * vb.h
|
|
|
|
// Collect gradient definitions for resolving url(#id) fills
|
|
const gradients = new Map<string, string>()
|
|
function collectGradients(node: XastElement) {
|
|
const children = node.children
|
|
for (const child of children) {
|
|
if (child.type !== 'element') continue
|
|
if (child.name === 'linearGradient' || child.name === 'radialGradient') {
|
|
const id = child.attributes.id
|
|
if (id) {
|
|
const stops = child.children.filter((s): s is XastElement => s.type === 'element' && s.name === 'stop')
|
|
if (stops.length > 0) {
|
|
// Use first stop color as representative brand color
|
|
const color = stops[0].attributes['stop-color']
|
|
if (color) gradients.set(id, color)
|
|
}
|
|
}
|
|
}
|
|
if (child.children.length > 0) collectGradients(child)
|
|
}
|
|
}
|
|
collectGradients(svgNode)
|
|
|
|
function resolveGradientFill(fill: string): string {
|
|
if (!fill || !fill.startsWith('url(')) return fill
|
|
const m = fill.match(/url\(#([^)]+)\)/)
|
|
return m ? gradients.get(m[1]) || '' : ''
|
|
}
|
|
|
|
// Collect all <path> and <rect> info in document order
|
|
const paths: PathInfo[] = []
|
|
const rects: {
|
|
node: XastElement
|
|
parent: XastElement
|
|
w: number
|
|
h: number
|
|
fill: string
|
|
resolvedFill: string
|
|
}[] = []
|
|
|
|
function collectElements(node: XastElement) {
|
|
const children = node.children
|
|
for (const child of children) {
|
|
if (child.type !== 'element') continue
|
|
|
|
// Skip non-visual elements: <defs>, <mask>, <clipPath>
|
|
if (child.name === 'defs' || child.name === 'mask' || child.name === 'clipPath') continue
|
|
|
|
if (child.name === 'rect') {
|
|
const w = parseFloat(child.attributes.width || '0')
|
|
const h = parseFloat(child.attributes.height || '0')
|
|
const fill = child.attributes.fill || ''
|
|
const resolved = resolveGradientFill(fill)
|
|
rects.push({ node: child, parent: node, w, h, fill, resolvedFill: resolved })
|
|
}
|
|
|
|
if (child.name === 'path') {
|
|
const d = child.attributes.d || ''
|
|
if (!d) continue
|
|
|
|
const fill = child.attributes.fill || ''
|
|
const bounds = parseSvgPathBounds(d)
|
|
let area = 0
|
|
if (isFinite(bounds.minX)) {
|
|
// Account for transform="translate(x,y)"
|
|
const transformAttr = child.attributes.transform || ''
|
|
const tx = transformAttr.match(/translate\((-?[\d.]+)[,\s]+(-?[\d.]+)\)/)
|
|
if (tx) {
|
|
const dx = parseFloat(tx[1]) || 0
|
|
const dy = parseFloat(tx[2]) || 0
|
|
bounds.minX += dx
|
|
bounds.maxX += dx
|
|
bounds.minY += dy
|
|
bounds.maxY += dy
|
|
}
|
|
area = (bounds.maxX - bounds.minX) * (bounds.maxY - bounds.minY)
|
|
}
|
|
|
|
const resolved = resolveGradientFill(fill)
|
|
const lum = resolved ? colorToLuminance(resolved) : 0 // no fill attr → implicit black
|
|
paths.push({
|
|
node: child,
|
|
parent: node,
|
|
d,
|
|
area,
|
|
fill,
|
|
resolvedFill: resolved,
|
|
lum,
|
|
cmdCount: pathCommandCount(d),
|
|
isWhite: isWhiteFill(resolved)
|
|
})
|
|
}
|
|
|
|
// Recurse into child elements (groups, etc.)
|
|
if (child.children && child.children.length > 0) {
|
|
collectElements(child)
|
|
}
|
|
}
|
|
}
|
|
|
|
collectElements(svgNode)
|
|
|
|
// Helper: mark background detected, optionally remove from AST.
|
|
// In detectOnly mode, white backgrounds (fill === null) are still removed
|
|
// since they are never brand elements and break dark mode rendering.
|
|
function markBackground(node: XastElement, parent: XastElement, fill: string | null) {
|
|
if (fill) backgroundFill = fill
|
|
if (!detectOnly || !fill) detachNodeFromParent(node, parent)
|
|
removed = true
|
|
}
|
|
|
|
// --- Rule 1: <rect> covering >= 70% viewBox → remove ---
|
|
for (const rect of rects) {
|
|
if (rect.w >= vb.w * 0.7 && rect.h >= vb.h * 0.7) {
|
|
const color = rect.resolvedFill || rect.fill
|
|
const bg = color && !isWhiteFill(color) && color !== 'none' ? color : null
|
|
markBackground(rect.node, rect.parent, bg)
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!removed) {
|
|
// --- Rule 2: First <path> matching rounded-rect pattern AND >= 70% viewBox ---
|
|
for (const p of paths) {
|
|
if (isRoundedRectPath(p.d) && p.area >= vbArea * 0.7) {
|
|
const color = p.resolvedFill || p.fill
|
|
const bg = color && !isWhiteFill(color) && color !== 'none' ? color : null
|
|
markBackground(p.node, p.parent, bg)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!removed) {
|
|
// Check if there are white foreground paths (indicates dark bg + light content pattern)
|
|
const hasWhiteFg = paths.some((p) => p.isWhite)
|
|
|
|
if (hasWhiteFg) {
|
|
// Separate large non-white paths (potential backgrounds)
|
|
const largeNonWhitePaths = paths.filter(
|
|
(p) => !p.isWhite && p.fill !== 'none' && isLargeShape(p.d, vb.w, vb.h, 0.3)
|
|
)
|
|
|
|
// --- Rule 3: Largest <path> covering >= 90% viewBox AND dark (lum < 0.15) → remove ---
|
|
const sorted = [...largeNonWhitePaths].sort((a, b) => b.area - a.area)
|
|
if (sorted.length >= 1 && sorted[0].area >= vbArea * 0.9 && sorted[0].lum >= 0 && sorted[0].lum < 0.15) {
|
|
markBackground(sorted[0].node, sorted[0].parent, sorted[0].resolvedFill || sorted[0].fill || '#000000')
|
|
}
|
|
|
|
// --- Rule 3b: Dominant dark shape covering >= 60% with multiple large shapes ---
|
|
if (
|
|
!removed &&
|
|
sorted.length >= 2 &&
|
|
sorted[0].area >= vbArea * 0.6 &&
|
|
sorted[0].lum >= 0 &&
|
|
sorted[0].lum < 0.15
|
|
) {
|
|
markBackground(sorted[0].node, sorted[0].parent, sorted[0].resolvedFill || sorted[0].fill || '#000000')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!removed) {
|
|
// --- Rule 3c: First <path> covering >= 85% viewBox with near-white foreground ---
|
|
// Targets vectorized (traced) backgrounds from @neplex/vectorizer output.
|
|
// The vectorizer produces a large filled path as the first element (background),
|
|
// followed by lighter detail paths (e.g. IBM icon with #002D72 bg and #CDDDEC/#EFF4F9 text).
|
|
const hasNearWhiteFg = paths.some(
|
|
(p) => isNearWhiteFill(p.resolvedFill || p.fill) && !isLargeShape(p.d, vb.w, vb.h, 0.85)
|
|
)
|
|
|
|
if (hasNearWhiteFg && paths.length >= 2) {
|
|
const first = paths[0]
|
|
// Check that remaining paths are all light / white (true vectorized bg pattern).
|
|
// If there are distinctly colored (low-luminance) paths among the remaining, the first
|
|
// path is likely part of the icon design (e.g. aihubmix circle + white cutout + blue smile),
|
|
// not a background.
|
|
const remainingPaths = paths.slice(1)
|
|
const hasColoredForeground = remainingPaths.some((p) => {
|
|
const fill = p.resolvedFill || p.fill
|
|
if (!fill || fill === 'none' || isWhiteFill(fill)) return false
|
|
const lum = colorToLuminance(fill)
|
|
return lum >= 0 && lum < 0.7
|
|
})
|
|
|
|
if (
|
|
!hasColoredForeground &&
|
|
first.area >= vbArea * 0.85 &&
|
|
first.lum >= 0 &&
|
|
first.lum < 0.5 &&
|
|
!isNearWhiteFill(first.resolvedFill || first.fill)
|
|
) {
|
|
markBackground(first.node, first.parent, first.resolvedFill || first.fill || '#000000')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!removed) {
|
|
// --- Rule 4: First large white <path> covering >= 60% viewBox → remove ---
|
|
for (const p of paths) {
|
|
if (p.isWhite && p.area >= vbArea * 0.6 && p.cmdCount <= 10) {
|
|
// Only remove simple white backgrounds, not complex white shapes (like huggingface body)
|
|
markBackground(p.node, p.parent, null)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return {}
|
|
}
|
|
} satisfies CustomPlugin
|
|
|
|
return {
|
|
plugin,
|
|
getBackgroundFill: () => backgroundFill,
|
|
wasRemoved: () => removed
|
|
}
|
|
}
|