🐛 fix(brand): 统一图标资源并适配 macOS Dock
- 将 40 个重复 PNG 变体收敛为 10 个 WebP 品牌资源 - 品牌选择器、关于页、favicon 与 Dock 统一复用所选图标 - 按 824px 安全区和 184px 标准圆角合成 macOS Dock 图标 - 补充资源映射、品牌展示和 Dock 合成回归测试
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 144 KiB |
BIN
frontend/public/brand-icons/01-database-hug.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 142 KiB |
BIN
frontend/public/brand-icons/02-database-search.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 450 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 164 KiB |
BIN
frontend/public/brand-icons/03-bandana-badge.webp
Normal file
|
After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 130 KiB |
BIN
frontend/public/brand-icons/04-magnifier-wink.webp
Normal file
|
After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 121 KiB |
BIN
frontend/public/brand-icons/05-window-peek.webp
Normal file
|
After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 136 KiB |
BIN
frontend/public/brand-icons/06-hex-collar.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 132 KiB |
BIN
frontend/public/brand-icons/07-graph-sit.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 124 KiB |
BIN
frontend/public/brand-icons/08-cloud-banner.webp
Normal file
|
After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 136 KiB |
BIN
frontend/public/brand-icons/09-terminal-sit.webp
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 160 KiB |
BIN
frontend/public/brand-icons/10-compass-bandana.webp
Normal file
|
After Width: | Height: | Size: 140 KiB |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 8,
|
||||
"version": 9,
|
||||
"defaultId": "02",
|
||||
"icons": [
|
||||
{
|
||||
@@ -7,80 +7,80 @@
|
||||
"slug": "database-hug",
|
||||
"titleZh": "抱库小狗",
|
||||
"titleEn": "Database hug",
|
||||
"iconPath": "/brand-icons/01-database-hug.png",
|
||||
"fullPath": "/brand-icons/01-database-hug-full.png"
|
||||
"iconPath": "/brand-icons/01-database-hug.webp",
|
||||
"fullPath": "/brand-icons/01-database-hug.webp"
|
||||
},
|
||||
{
|
||||
"id": "02",
|
||||
"slug": "database-search",
|
||||
"titleZh": "搜库小狗",
|
||||
"titleEn": "Database search",
|
||||
"iconPath": "/brand-icons/02-database-search.png",
|
||||
"fullPath": "/brand-icons/02-database-search-full.png"
|
||||
"iconPath": "/brand-icons/02-database-search.webp",
|
||||
"fullPath": "/brand-icons/02-database-search.webp"
|
||||
},
|
||||
{
|
||||
"id": "03",
|
||||
"slug": "bandana-badge",
|
||||
"titleZh": "头巾徽章",
|
||||
"titleEn": "Bandana badge",
|
||||
"iconPath": "/brand-icons/03-bandana-badge.png",
|
||||
"fullPath": "/brand-icons/03-bandana-badge-full.png"
|
||||
"iconPath": "/brand-icons/03-bandana-badge.webp",
|
||||
"fullPath": "/brand-icons/03-bandana-badge.webp"
|
||||
},
|
||||
{
|
||||
"id": "04",
|
||||
"slug": "magnifier-wink",
|
||||
"titleZh": "放大镜眨眼",
|
||||
"titleEn": "Magnifier wink",
|
||||
"iconPath": "/brand-icons/04-magnifier-wink.png",
|
||||
"fullPath": "/brand-icons/04-magnifier-wink-full.png"
|
||||
"iconPath": "/brand-icons/04-magnifier-wink.webp",
|
||||
"fullPath": "/brand-icons/04-magnifier-wink.webp"
|
||||
},
|
||||
{
|
||||
"id": "05",
|
||||
"slug": "window-peek",
|
||||
"titleZh": "窗口探头",
|
||||
"titleEn": "Window peek",
|
||||
"iconPath": "/brand-icons/05-window-peek.png",
|
||||
"fullPath": "/brand-icons/05-window-peek-full.png"
|
||||
"iconPath": "/brand-icons/05-window-peek.webp",
|
||||
"fullPath": "/brand-icons/05-window-peek.webp"
|
||||
},
|
||||
{
|
||||
"id": "06",
|
||||
"slug": "hex-collar",
|
||||
"titleZh": "六边项圈",
|
||||
"titleEn": "Hex collar",
|
||||
"iconPath": "/brand-icons/06-hex-collar.png",
|
||||
"fullPath": "/brand-icons/06-hex-collar-full.png"
|
||||
"iconPath": "/brand-icons/06-hex-collar.webp",
|
||||
"fullPath": "/brand-icons/06-hex-collar.webp"
|
||||
},
|
||||
{
|
||||
"id": "07",
|
||||
"slug": "graph-sit",
|
||||
"titleZh": "关系图",
|
||||
"titleEn": "Graph sit",
|
||||
"iconPath": "/brand-icons/07-graph-sit.png",
|
||||
"fullPath": "/brand-icons/07-graph-sit-full.png"
|
||||
"iconPath": "/brand-icons/07-graph-sit.webp",
|
||||
"fullPath": "/brand-icons/07-graph-sit.webp"
|
||||
},
|
||||
{
|
||||
"id": "08",
|
||||
"slug": "cloud-banner",
|
||||
"titleZh": "云朵横幅",
|
||||
"titleEn": "Cloud banner",
|
||||
"iconPath": "/brand-icons/08-cloud-banner.png",
|
||||
"fullPath": "/brand-icons/08-cloud-banner-full.png"
|
||||
"iconPath": "/brand-icons/08-cloud-banner.webp",
|
||||
"fullPath": "/brand-icons/08-cloud-banner.webp"
|
||||
},
|
||||
{
|
||||
"id": "09",
|
||||
"slug": "terminal-sit",
|
||||
"titleZh": "终端旁坐",
|
||||
"titleEn": "Terminal sit",
|
||||
"iconPath": "/brand-icons/09-terminal-sit.png",
|
||||
"fullPath": "/brand-icons/09-terminal-sit-full.png"
|
||||
"iconPath": "/brand-icons/09-terminal-sit.webp",
|
||||
"fullPath": "/brand-icons/09-terminal-sit.webp"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"slug": "compass-bandana",
|
||||
"titleZh": "罗盘头巾",
|
||||
"titleEn": "Compass bandana",
|
||||
"iconPath": "/brand-icons/10-compass-bandana.png",
|
||||
"fullPath": "/brand-icons/10-compass-bandana-full.png"
|
||||
"iconPath": "/brand-icons/10-compass-bandana.webp",
|
||||
"fullPath": "/brand-icons/10-compass-bandana.webp"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -25,13 +25,18 @@ describe('about brand lockup', () => {
|
||||
expect(aboutLogoSnippet).toContain("boxShadow: 'none'");
|
||||
});
|
||||
|
||||
it('defines a transparent about asset for every selectable mascot', () => {
|
||||
const aboutAssetPaths = brandIconsSource.match(/aboutPath: '\/brand-icons\/\d{2}-.+-about\.png'/g) || [];
|
||||
expect(aboutAssetPaths).toHaveLength(10);
|
||||
for (const declaration of aboutAssetPaths) {
|
||||
it('keeps one shared lossless WebP asset for every selectable mascot', () => {
|
||||
const iconAssetPaths = brandIconsSource.match(/iconPath: '\/brand-icons\/\d{2}-.+\.webp'/g) || [];
|
||||
expect(iconAssetPaths).toHaveLength(10);
|
||||
for (const declaration of iconAssetPaths) {
|
||||
const assetPath = declaration.match(/'([^']+)'/)?.[1];
|
||||
expect(assetPath).toBeTruthy();
|
||||
expect(readFileSync(`${brandIconsDirectory}${assetPath?.replace('/brand-icons/', '')}`, 'utf8')).not.toBe('');
|
||||
}
|
||||
|
||||
const webpFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.webp'));
|
||||
expect(webpFiles).toHaveLength(10);
|
||||
const legacyPngFiles = readdirSync(brandIconsDirectory).filter((file) => file.endsWith('.png'));
|
||||
expect(legacyPngFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -814,7 +814,7 @@ function App() {
|
||||
link.setAttribute('data-brand-icon', 'true');
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/png';
|
||||
link.type = 'image/webp';
|
||||
link.href = href;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
20
frontend/src/brand/brandIcons.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BRAND_ICONS,
|
||||
resolveBrandAboutSrc,
|
||||
resolveBrandDockSrc,
|
||||
resolveBrandFullSrc,
|
||||
resolveBrandIconSrc,
|
||||
} from './brandIcons';
|
||||
|
||||
describe('brand icon asset resolution', () => {
|
||||
it('uses one selected lossless asset for the favicon, picker, about page, and Dock', () => {
|
||||
for (const icon of BRAND_ICONS) {
|
||||
const selectedAsset = resolveBrandIconSrc(icon.id);
|
||||
expect(selectedAsset).toMatch(/^\/brand-icons\/\d{2}-.+\.webp$/);
|
||||
expect(resolveBrandFullSrc(icon.id)).toBe(selectedAsset);
|
||||
expect(resolveBrandAboutSrc(icon.id)).toBe(selectedAsset);
|
||||
expect(resolveBrandDockSrc(icon.id)).toBe(selectedAsset);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,6 @@ export type BrandIconDefinition = {
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
iconPath: string;
|
||||
fullPath: string;
|
||||
/** Transparent complete lockup used by the About surface. */
|
||||
aboutPath: string;
|
||||
/** Opaque source tile composed into a rounded macOS Dock image at runtime. */
|
||||
dockPath: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_BRAND_ICON_ID: BrandIconId = '02';
|
||||
@@ -31,100 +26,70 @@ export const BRAND_ICONS: BrandIconDefinition[] = [
|
||||
slug: 'database-hug',
|
||||
titleZh: '抱库小狗',
|
||||
titleEn: 'Database hug',
|
||||
iconPath: '/brand-icons/01-database-hug.png',
|
||||
fullPath: '/brand-icons/01-database-hug-full.png',
|
||||
aboutPath: '/brand-icons/01-database-hug-about.png',
|
||||
dockPath: '/brand-icons/01-database-hug-dock.png',
|
||||
iconPath: '/brand-icons/01-database-hug.webp',
|
||||
},
|
||||
{
|
||||
id: '02',
|
||||
slug: 'database-search',
|
||||
titleZh: '搜库小狗',
|
||||
titleEn: 'Database search',
|
||||
iconPath: '/brand-icons/02-database-search.png',
|
||||
fullPath: '/brand-icons/02-database-search-full.png',
|
||||
aboutPath: '/brand-icons/02-database-search-about.png',
|
||||
dockPath: '/brand-icons/02-database-search-dock.png',
|
||||
iconPath: '/brand-icons/02-database-search.webp',
|
||||
},
|
||||
{
|
||||
id: '03',
|
||||
slug: 'bandana-badge',
|
||||
titleZh: '头巾徽章',
|
||||
titleEn: 'Bandana badge',
|
||||
iconPath: '/brand-icons/03-bandana-badge.png',
|
||||
fullPath: '/brand-icons/03-bandana-badge-full.png',
|
||||
aboutPath: '/brand-icons/03-bandana-badge-about.png',
|
||||
dockPath: '/brand-icons/03-bandana-badge-dock.png',
|
||||
iconPath: '/brand-icons/03-bandana-badge.webp',
|
||||
},
|
||||
{
|
||||
id: '04',
|
||||
slug: 'magnifier-wink',
|
||||
titleZh: '放大镜眨眼',
|
||||
titleEn: 'Magnifier wink',
|
||||
iconPath: '/brand-icons/04-magnifier-wink.png',
|
||||
fullPath: '/brand-icons/04-magnifier-wink-full.png',
|
||||
aboutPath: '/brand-icons/04-magnifier-wink-about.png',
|
||||
dockPath: '/brand-icons/04-magnifier-wink-dock.png',
|
||||
iconPath: '/brand-icons/04-magnifier-wink.webp',
|
||||
},
|
||||
{
|
||||
id: '05',
|
||||
slug: 'window-peek',
|
||||
titleZh: '窗口探头',
|
||||
titleEn: 'Window peek',
|
||||
iconPath: '/brand-icons/05-window-peek.png',
|
||||
fullPath: '/brand-icons/05-window-peek-full.png',
|
||||
aboutPath: '/brand-icons/05-window-peek-about.png',
|
||||
dockPath: '/brand-icons/05-window-peek-dock.png',
|
||||
iconPath: '/brand-icons/05-window-peek.webp',
|
||||
},
|
||||
{
|
||||
id: '06',
|
||||
slug: 'hex-collar',
|
||||
titleZh: '六边项圈',
|
||||
titleEn: 'Hex collar',
|
||||
iconPath: '/brand-icons/06-hex-collar.png',
|
||||
fullPath: '/brand-icons/06-hex-collar-full.png',
|
||||
aboutPath: '/brand-icons/06-hex-collar-about.png',
|
||||
dockPath: '/brand-icons/06-hex-collar-dock.png',
|
||||
iconPath: '/brand-icons/06-hex-collar.webp',
|
||||
},
|
||||
{
|
||||
id: '07',
|
||||
slug: 'graph-sit',
|
||||
titleZh: '关系图',
|
||||
titleEn: 'Graph sit',
|
||||
iconPath: '/brand-icons/07-graph-sit.png',
|
||||
fullPath: '/brand-icons/07-graph-sit-full.png',
|
||||
aboutPath: '/brand-icons/07-graph-sit-about.png',
|
||||
dockPath: '/brand-icons/07-graph-sit-dock.png',
|
||||
iconPath: '/brand-icons/07-graph-sit.webp',
|
||||
},
|
||||
{
|
||||
id: '08',
|
||||
slug: 'cloud-banner',
|
||||
titleZh: '云朵横幅',
|
||||
titleEn: 'Cloud banner',
|
||||
iconPath: '/brand-icons/08-cloud-banner.png',
|
||||
fullPath: '/brand-icons/08-cloud-banner-full.png',
|
||||
aboutPath: '/brand-icons/08-cloud-banner-about.png',
|
||||
dockPath: '/brand-icons/08-cloud-banner-dock.png',
|
||||
iconPath: '/brand-icons/08-cloud-banner.webp',
|
||||
},
|
||||
{
|
||||
id: '09',
|
||||
slug: 'terminal-sit',
|
||||
titleZh: '终端旁坐',
|
||||
titleEn: 'Terminal sit',
|
||||
iconPath: '/brand-icons/09-terminal-sit.png',
|
||||
fullPath: '/brand-icons/09-terminal-sit-full.png',
|
||||
aboutPath: '/brand-icons/09-terminal-sit-about.png',
|
||||
dockPath: '/brand-icons/09-terminal-sit-dock.png',
|
||||
iconPath: '/brand-icons/09-terminal-sit.webp',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
slug: 'compass-bandana',
|
||||
titleZh: '罗盘头巾',
|
||||
titleEn: 'Compass bandana',
|
||||
iconPath: '/brand-icons/10-compass-bandana.png',
|
||||
fullPath: '/brand-icons/10-compass-bandana-full.png',
|
||||
aboutPath: '/brand-icons/10-compass-bandana-about.png',
|
||||
dockPath: '/brand-icons/10-compass-bandana-dock.png',
|
||||
iconPath: '/brand-icons/10-compass-bandana.webp',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -147,14 +112,14 @@ export function resolveBrandIconSrc(id?: unknown): string {
|
||||
}
|
||||
|
||||
export function resolveBrandFullSrc(id?: unknown): string {
|
||||
return resolveBrandIcon(id).fullPath;
|
||||
return resolveBrandIconSrc(id);
|
||||
}
|
||||
|
||||
export function resolveBrandAboutSrc(id?: unknown): string {
|
||||
return resolveBrandIcon(id).aboutPath;
|
||||
return resolveBrandIconSrc(id);
|
||||
}
|
||||
|
||||
/** Opaque source tile for the rounded macOS Dock composition. */
|
||||
/** Dock uses the exact lossless WebP lockup rendered by BrandIconPicker. */
|
||||
export function resolveBrandDockSrc(id?: unknown): string {
|
||||
return resolveBrandIcon(id).dockPath;
|
||||
return resolveBrandIconSrc(id);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shouldSyncMacOSDockIcon } from './macDockIcon';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
calculateMacOSDockCornerRadius,
|
||||
calculateMacOSDockImageRect,
|
||||
composeMacOSDockIconBase64,
|
||||
shouldSyncMacOSDockIcon,
|
||||
} from './macDockIcon';
|
||||
|
||||
describe('shouldSyncMacOSDockIcon', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('only allows the native macOS runtime', () => {
|
||||
expect(shouldSyncMacOSDockIcon({ platform: 'darwin', buildType: 'production' })).toBe(true);
|
||||
expect(shouldSyncMacOSDockIcon({ platform: 'DARWIN', buildType: 'debug' })).toBe(true);
|
||||
@@ -13,4 +22,68 @@ describe('shouldSyncMacOSDockIcon', () => {
|
||||
expect(shouldSyncMacOSDockIcon({ platform: 'linux', buildType: 'production' })).toBe(false);
|
||||
expect(shouldSyncMacOSDockIcon()).toBe(false);
|
||||
});
|
||||
|
||||
it('fits square brand icons into the standard macOS Dock safe area', () => {
|
||||
const rect = calculateMacOSDockImageRect(512, 512);
|
||||
|
||||
expect(rect).toEqual({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 824,
|
||||
height: 824,
|
||||
});
|
||||
expect(calculateMacOSDockCornerRadius(rect)).toBe(184);
|
||||
});
|
||||
|
||||
it('centres portrait brand lockups without stretching them into a square', () => {
|
||||
expect(calculateMacOSDockImageRect(272, 449)).toEqual({
|
||||
x: 263,
|
||||
y: 100,
|
||||
width: 499,
|
||||
height: 824,
|
||||
});
|
||||
});
|
||||
|
||||
it('clips the complete brand image to the standard macOS rounded tile before drawing', async () => {
|
||||
const calls: string[] = [];
|
||||
const arcTo = vi.fn((...args: number[]) => calls.push(`arcTo:${args[4]}`));
|
||||
const context = {
|
||||
beginPath: vi.fn(() => calls.push('beginPath')),
|
||||
moveTo: vi.fn(() => calls.push('moveTo')),
|
||||
lineTo: vi.fn(() => calls.push('lineTo')),
|
||||
arcTo,
|
||||
closePath: vi.fn(() => calls.push('closePath')),
|
||||
clip: vi.fn(() => calls.push('clip')),
|
||||
drawImage: vi.fn(() => calls.push('drawImage')),
|
||||
imageSmoothingEnabled: false,
|
||||
imageSmoothingQuality: 'low',
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: vi.fn(() => context),
|
||||
toDataURL: vi.fn(() => 'data:image/png;base64,encoded'),
|
||||
} as unknown as HTMLCanvasElement;
|
||||
|
||||
class FakeImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
naturalWidth = 512;
|
||||
naturalHeight = 512;
|
||||
width = 512;
|
||||
height = 512;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('Image', FakeImage);
|
||||
vi.stubGlobal('document', { createElement: vi.fn(() => canvas) });
|
||||
|
||||
await expect(composeMacOSDockIconBase64('/brand-icons/09-terminal-sit.webp')).resolves.toBe('encoded');
|
||||
expect(arcTo.mock.calls.map((args) => args[4])).toEqual([184, 184, 184, 184]);
|
||||
expect(calls.indexOf('clip')).toBeGreaterThan(calls.indexOf('beginPath'));
|
||||
expect(calls.indexOf('drawImage')).toBeGreaterThan(calls.indexOf('clip'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
const DOCK_ICON_SIZE = 1024;
|
||||
const DOCK_ICON_INSET = 24;
|
||||
const DOCK_ICON_CORNER_RADIUS = 216;
|
||||
// Chrome and VS Code both keep their high-alpha artwork inside an 824px
|
||||
// square on a 1024px macOS icon canvas.
|
||||
const DOCK_ICON_INSET = 100;
|
||||
// Chrome's 824px tile uses a 184px outer corner radius.
|
||||
const DOCK_ICON_CORNER_RADIUS_RATIO = 184 / 824;
|
||||
|
||||
export type DockIconRuntimeEnvironment = {
|
||||
platform?: unknown;
|
||||
buildType?: unknown;
|
||||
};
|
||||
|
||||
export type MacOSDockImageRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Only the native macOS runtime can update the Dock image. The generated
|
||||
* Wails bridge also exists in the browser build, so checking method presence
|
||||
@@ -32,37 +42,53 @@ function canvasToBase64Png(canvas: HTMLCanvasElement): string {
|
||||
return comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
||||
}
|
||||
|
||||
function addRoundedRectPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): void {
|
||||
const clampedRadius = Math.min(radius, width / 2, height / 2);
|
||||
export function calculateMacOSDockImageRect(imageWidth: number, imageHeight: number): MacOSDockImageRect {
|
||||
const tileSize = DOCK_ICON_SIZE - (DOCK_ICON_INSET * 2);
|
||||
const sourceWidth = Math.max(1, Number(imageWidth) || tileSize);
|
||||
const sourceHeight = Math.max(1, Number(imageHeight) || tileSize);
|
||||
const scale = Math.min(tileSize / sourceWidth, tileSize / sourceHeight);
|
||||
const width = Math.round(sourceWidth * scale);
|
||||
const height = Math.round(sourceHeight * scale);
|
||||
|
||||
return {
|
||||
x: Math.round((DOCK_ICON_SIZE - width) / 2),
|
||||
y: Math.round((DOCK_ICON_SIZE - height) / 2),
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateMacOSDockCornerRadius(rect: MacOSDockImageRect): number {
|
||||
return Math.round(Math.min(rect.width, rect.height) * DOCK_ICON_CORNER_RADIUS_RATIO);
|
||||
}
|
||||
|
||||
function clipMacOSDockImage(ctx: CanvasRenderingContext2D, rect: MacOSDockImageRect): void {
|
||||
const radius = calculateMacOSDockCornerRadius(rect);
|
||||
const right = rect.x + rect.width;
|
||||
const bottom = rect.y + rect.height;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + clampedRadius, y);
|
||||
ctx.lineTo(x + width - clampedRadius, y);
|
||||
ctx.arcTo(x + width, y, x + width, y + clampedRadius, clampedRadius);
|
||||
ctx.lineTo(x + width, y + height - clampedRadius);
|
||||
ctx.arcTo(x + width, y + height, x + width - clampedRadius, y + height, clampedRadius);
|
||||
ctx.lineTo(x + clampedRadius, y + height);
|
||||
ctx.arcTo(x, y + height, x, y + height - clampedRadius, clampedRadius);
|
||||
ctx.lineTo(x, y + clampedRadius);
|
||||
ctx.arcTo(x, y, x + clampedRadius, y, clampedRadius);
|
||||
ctx.moveTo(rect.x + radius, rect.y);
|
||||
ctx.lineTo(right - radius, rect.y);
|
||||
ctx.arcTo(right, rect.y, right, rect.y + radius, radius);
|
||||
ctx.lineTo(right, bottom - radius);
|
||||
ctx.arcTo(right, bottom, right - radius, bottom, radius);
|
||||
ctx.lineTo(rect.x + radius, bottom);
|
||||
ctx.arcTo(rect.x, bottom, rect.x, bottom - radius, radius);
|
||||
ctx.lineTo(rect.x, rect.y + radius);
|
||||
ctx.arcTo(rect.x, rect.y, rect.x + radius, rect.y, radius);
|
||||
ctx.closePath();
|
||||
ctx.clip();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the selected opaque brand tile into a rounded, transparent PNG.
|
||||
* The transparent outer pixels let macOS apply its native Dock treatment
|
||||
* instead of displaying the source image as a full square.
|
||||
* Place the selected complete brand icon on a transparent macOS canvas.
|
||||
* Keep the source artwork intact while normalising its outer tile to the
|
||||
* standard macOS corner geometry.
|
||||
*/
|
||||
export async function composeMacOSDockIconBase64(src: string): Promise<string> {
|
||||
const img = await loadImage(src);
|
||||
const size = DOCK_ICON_SIZE;
|
||||
const tileSize = size - (DOCK_ICON_INSET * 2);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
@@ -70,15 +96,10 @@ export async function composeMacOSDockIconBase64(src: string): Promise<string> {
|
||||
if (!ctx) {
|
||||
throw new Error('2d context unavailable');
|
||||
}
|
||||
ctx.save();
|
||||
addRoundedRectPath(ctx, DOCK_ICON_INSET, DOCK_ICON_INSET, tileSize, tileSize, DOCK_ICON_CORNER_RADIUS);
|
||||
ctx.clip();
|
||||
// Opaque soft-blue fallback under draw (matches the pre-baked dock tiles).
|
||||
ctx.fillStyle = '#E8F4FF';
|
||||
ctx.fillRect(DOCK_ICON_INSET, DOCK_ICON_INSET, tileSize, tileSize);
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, DOCK_ICON_INSET, DOCK_ICON_INSET, tileSize, tileSize);
|
||||
ctx.restore();
|
||||
const rect = calculateMacOSDockImageRect(img.naturalWidth || img.width, img.naturalHeight || img.height);
|
||||
clipMacOSDockImage(ctx, rect);
|
||||
ctx.drawImage(img, rect.x, rect.y, rect.width, rect.height);
|
||||
return canvasToBase64Png(canvas);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function BrandIconPicker({ value, onChange, darkMode = false }: B
|
||||
overflow: 'visible',
|
||||
}}
|
||||
>
|
||||
{/* Use full lockup so the complete logo is visible in the picker */}
|
||||
{/* Each picker card uses the same lossless source as every app surface. */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -61,7 +61,7 @@ export default function BrandIconPicker({ value, onChange, darkMode = false }: B
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={item.fullPath}
|
||||
src={item.iconPath}
|
||||
alt={item.titleZh}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
|
||||