mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
fix(glass): preserve plugin brand accents (#658)
This commit is contained in:
@@ -1,11 +1,73 @@
|
|||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('colorthief', () => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
default: class ColorThief {},
|
getColor: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { preloadCorsImage } from '@/@core/utils/image'
|
vi.mock('colorthief', () => ({
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
default: class ColorThief {
|
||||||
|
getColor(image: HTMLImageElement, quality?: number) {
|
||||||
|
return mocks.getColor(image, quality)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { extractDominantColor, getDominantColor, preloadCorsImage } from '@/@core/utils/image'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
function createImage(cacheKey: string) {
|
||||||
|
const image = document.createElement('img')
|
||||||
|
Object.defineProperty(image, 'currentSrc', { configurable: true, value: `https://image.example/${cacheKey}.png` })
|
||||||
|
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dominant color extraction', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getColor.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shares a pending extraction and reuses only the successful result', async () => {
|
||||||
|
const image = createImage('shared-success')
|
||||||
|
mocks.getColor.mockReturnValue([18, 52, 86])
|
||||||
|
|
||||||
|
await expect(Promise.all([extractDominantColor(image), extractDominantColor(image)])).resolves.toEqual([
|
||||||
|
'#123456',
|
||||||
|
'#123456',
|
||||||
|
])
|
||||||
|
await expect(extractDominantColor(image)).resolves.toBe('#123456')
|
||||||
|
expect(mocks.getColor).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not cache failures or let one caller fallback pollute another', async () => {
|
||||||
|
const image = createImage('retry-after-failure')
|
||||||
|
mocks.getColor.mockImplementation(() => {
|
||||||
|
throw new Error('tainted canvas')
|
||||||
|
})
|
||||||
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
await expect(getDominantColor(image, { fallback: '#111111' })).resolves.toBe('#111111')
|
||||||
|
await expect(getDominantColor(image, { fallback: '#222222' })).resolves.toBe('#222222')
|
||||||
|
expect(mocks.getColor).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the existing default fallback contract for callers such as QuickAccess', async () => {
|
||||||
|
await expect(getDominantColor(null)).resolves.toBe('#28A9E1')
|
||||||
|
expect(mocks.getColor).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains the bounded FIFO success cache', async () => {
|
||||||
|
mocks.getColor.mockReturnValue([1, 2, 3])
|
||||||
|
|
||||||
|
for (let index = 0; index <= 100; index += 1) await extractDominantColor(createImage(`fifo-${index}`))
|
||||||
|
|
||||||
|
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||||
|
await extractDominantColor(createImage('fifo-50'))
|
||||||
|
expect(mocks.getColor).toHaveBeenCalledTimes(101)
|
||||||
|
await extractDominantColor(createImage('fifo-0'))
|
||||||
|
expect(mocks.getColor).toHaveBeenCalledTimes(102)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('preloadCorsImage', () => {
|
describe('preloadCorsImage', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
+41
-22
@@ -4,7 +4,8 @@ export { preloadCorsImage } from './corsImage'
|
|||||||
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
const DEFAULT_DOMINANT_COLOR = '#28A9E1'
|
||||||
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
const DOMINANT_COLOR_CACHE_LIMIT = 100
|
||||||
const colorThief = new ColorThief()
|
const colorThief = new ColorThief()
|
||||||
const dominantColorCache = new Map<string, Promise<string>>()
|
const dominantColorCache = new Map<string, string>()
|
||||||
|
const pendingDominantColorRequests = new Map<string, Promise<string | undefined>>()
|
||||||
|
|
||||||
interface DominantColorOptions {
|
interface DominantColorOptions {
|
||||||
fallback?: string
|
fallback?: string
|
||||||
@@ -29,42 +30,60 @@ function getImageCacheKey(image: HTMLImageElement) {
|
|||||||
return image.currentSrc || image.src || ''
|
return image.currentSrc || image.src || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function rememberDominantColor(key: string, colorPromise: Promise<string>) {
|
function rememberDominantColor(key: string, color: string) {
|
||||||
if (!key) return colorPromise
|
if (!key) return
|
||||||
|
|
||||||
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
if (dominantColorCache.size >= DOMINANT_COLOR_CACHE_LIMIT) {
|
||||||
const firstKey = dominantColorCache.keys().next().value
|
const firstKey = dominantColorCache.keys().next().value
|
||||||
if (firstKey) dominantColorCache.delete(firstKey)
|
if (firstKey) dominantColorCache.delete(firstKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
dominantColorCache.set(key, colorPromise)
|
dominantColorCache.set(key, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取真实主色;失败不写入成功缓存,允许后续请求重试。 */
|
||||||
|
export async function extractDominantColor(
|
||||||
|
image: HTMLImageElement | undefined | null,
|
||||||
|
options: Pick<DominantColorOptions, 'quality'> = {},
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!image) return undefined
|
||||||
|
|
||||||
|
const cacheKey = getImageCacheKey(image)
|
||||||
|
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
||||||
|
if (cachedColor) return cachedColor
|
||||||
|
|
||||||
|
const pendingRequest = cacheKey ? pendingDominantColorRequests.get(cacheKey) : undefined
|
||||||
|
if (pendingRequest) return pendingRequest
|
||||||
|
|
||||||
|
const colorPromise = Promise.resolve()
|
||||||
|
.then(() => {
|
||||||
|
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
||||||
|
const color = rgbStringToHex(dominantColor)
|
||||||
|
rememberDominantColor(cacheKey, color)
|
||||||
|
|
||||||
|
return color
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.warn('Failed to extract dominant color:', error)
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cacheKey) pendingDominantColorRequests.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cacheKey) pendingDominantColorRequests.set(cacheKey, colorPromise)
|
||||||
|
|
||||||
return colorPromise
|
return colorPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提取主要颜色
|
/** 提取主色并在失败时解析调用方 fallback,保持既有调用合同。 */
|
||||||
export async function getDominantColor(
|
export async function getDominantColor(
|
||||||
image: HTMLImageElement | undefined | null,
|
image: HTMLImageElement | undefined | null,
|
||||||
options: DominantColorOptions = {},
|
options: DominantColorOptions = {},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
const fallback = options.fallback ?? DEFAULT_DOMINANT_COLOR
|
||||||
|
|
||||||
if (!image) return fallback
|
return (await extractDominantColor(image, options)) ?? fallback
|
||||||
|
|
||||||
const cacheKey = getImageCacheKey(image)
|
|
||||||
const cachedColor = cacheKey ? dominantColorCache.get(cacheKey) : undefined
|
|
||||||
if (cachedColor) return cachedColor
|
|
||||||
|
|
||||||
const colorPromise = Promise.resolve()
|
|
||||||
.then(() => {
|
|
||||||
const dominantColor = colorThief.getColor(image, options.quality ?? 20)
|
|
||||||
return rgbStringToHex(dominantColor)
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.warn('Failed to extract dominant color:', error)
|
|
||||||
return fallback
|
|
||||||
})
|
|
||||||
|
|
||||||
return rememberDominantColor(cacheKey, colorPromise)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 预加载图片
|
// 预加载图片
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, Plugin } from '@/api/types'
|
import type { ApiResponse, Plugin } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -35,11 +35,7 @@ const $toast = useToast()
|
|||||||
|
|
||||||
const createConfirm = useConfirm()
|
const createConfirm = useConfirm()
|
||||||
|
|
||||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||||
const accentRgb = ref('40, 169, 225')
|
|
||||||
|
|
||||||
// 图片对象
|
|
||||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
|
||||||
|
|
||||||
// 获取当前插件的标签
|
// 获取当前插件的标签
|
||||||
const pluginLabels = computed(() => {
|
const pluginLabels = computed(() => {
|
||||||
@@ -71,9 +67,12 @@ function closeInstallProgress() {
|
|||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
await updateAccentColor()
|
||||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
}
|
||||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
|
||||||
|
function imageFailed() {
|
||||||
|
imageLoadError.value = true
|
||||||
|
resetAccentColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算图标路径
|
// 计算图标路径
|
||||||
@@ -237,7 +236,7 @@ onUnmounted(() => {
|
|||||||
:class="{
|
:class="{
|
||||||
'app-hover-lift-card--hovering': hover.isHovering,
|
'app-hover-lift-card--hovering': hover.isHovering,
|
||||||
}"
|
}"
|
||||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
:style="accentStyle"
|
||||||
>
|
>
|
||||||
<div class="plugin-card__banner flex-grow">
|
<div class="plugin-card__banner flex-grow">
|
||||||
<VCardText class="px-2 pt-2 pb-0">
|
<VCardText class="px-2 pt-2 pb-0">
|
||||||
@@ -280,7 +279,7 @@ onUnmounted(() => {
|
|||||||
aspect-ratio="4/3"
|
aspect-ratio="4/3"
|
||||||
cover
|
cover
|
||||||
@load="imageLoaded"
|
@load="imageLoaded"
|
||||||
@error="imageLoadError = true"
|
@error="imageFailed"
|
||||||
/>
|
/>
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useConfirm } from '@/composables/useConfirm'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
import type { ApiResponse, Plugin, PluginRating } from '@/api/types'
|
||||||
import { getLogoUrl } from '@/utils/imageUtils'
|
import { getLogoUrl } from '@/utils/imageUtils'
|
||||||
import { getCardAccentRgbFromImage } from '@/composables/useCardAccentColor'
|
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -52,11 +52,7 @@ const cardRatingSummary = computed(() =>
|
|||||||
// 显示器宽度
|
// 显示器宽度
|
||||||
const display = useDisplay()
|
const display = useDisplay()
|
||||||
|
|
||||||
// 卡片头部染色所用的图标主色(CSS 变量可直接消费的 RGB 通道值)
|
const { accentStyle, imageRef, resetAccentColor, updateAccentColor } = usePluginCardAccent()
|
||||||
const accentRgb = ref('40, 169, 225')
|
|
||||||
|
|
||||||
// 图片对象
|
|
||||||
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
@@ -108,9 +104,12 @@ watch(
|
|||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
async function imageLoaded() {
|
async function imageLoaded() {
|
||||||
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
|
await updateAccentColor()
|
||||||
// 从图标中提取主色,作为卡片头部染色玻璃的色相来源
|
}
|
||||||
accentRgb.value = await getCardAccentRgbFromImage(imageElement, '#28A9E1')
|
|
||||||
|
function imageFailed() {
|
||||||
|
imageLoadError.value = true
|
||||||
|
resetAccentColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示更新日志
|
// 显示更新日志
|
||||||
@@ -646,7 +645,7 @@ watch(
|
|||||||
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
'app-hover-lift-card--hovering': hover.isHovering && !props.sortable,
|
||||||
'cursor-move': props.sortable,
|
'cursor-move': props.sortable,
|
||||||
}"
|
}"
|
||||||
:style="{ '--plugin-card-accent-rgb': accentRgb }"
|
:style="accentStyle"
|
||||||
:ripple="!props.sortable"
|
:ripple="!props.sortable"
|
||||||
>
|
>
|
||||||
<div class="plugin-card__banner flex-grow">
|
<div class="plugin-card__banner flex-grow">
|
||||||
@@ -677,7 +676,7 @@ watch(
|
|||||||
aspect-ratio="4/3"
|
aspect-ratio="4/3"
|
||||||
cover
|
cover
|
||||||
@load="imageLoaded"
|
@load="imageLoaded"
|
||||||
@error="imageLoadError = true"
|
@error="imageFailed"
|
||||||
/>
|
/>
|
||||||
</VAvatar>
|
</VAvatar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
import PluginAppCard from '@/components/cards/PluginAppCard.vue'
|
||||||
|
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
@@ -31,8 +32,8 @@ vi.mock('vue-toastification', () => ({
|
|||||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
extractDominantColor: mocks.accentFromImage,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const plugin: Plugin = {
|
const plugin: Plugin = {
|
||||||
@@ -47,12 +48,13 @@ const plugin: Plugin = {
|
|||||||
const ImageStub = defineComponent({
|
const ImageStub = defineComponent({
|
||||||
name: 'VImg',
|
name: 'VImg',
|
||||||
emits: ['error', 'load'],
|
emits: ['error', 'load'],
|
||||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
template:
|
||||||
|
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('PluginAppCard rating badge', () => {
|
describe('PluginAppCard rating badge', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
mocks.confirm.mockReset().mockResolvedValue(true)
|
mocks.confirm.mockReset().mockResolvedValue(true)
|
||||||
mocks.dialogCloses.length = 0
|
mocks.dialogCloses.length = 0
|
||||||
@@ -232,7 +234,20 @@ describe('PluginAppCard rating badge', () => {
|
|||||||
const image = screen.getByTestId('plugin-image')
|
const image = screen.getByTestId('plugin-image')
|
||||||
await fireEvent.click(image)
|
await fireEvent.click(image)
|
||||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||||
await fireEvent.contextMenu(image)
|
await fireEvent.contextMenu(image)
|
||||||
|
mocks.accentFromImage.mockResolvedValueOnce('#654321')
|
||||||
|
await fireEvent.click(image)
|
||||||
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe(normalizePluginAccentColor('#654321')?.rgb)
|
||||||
|
await fireEvent.contextMenu(image)
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe('')
|
||||||
|
|
||||||
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
await fireEvent.click(container.querySelector<HTMLButtonElement>('.v-card .v-btn')!)
|
||||||
await fireEvent.click(await screen.findByText('项目主页'))
|
await fireEvent.click(await screen.findByText('项目主页'))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Plugin } from '@/api/types'
|
import type { Plugin } from '@/api/types'
|
||||||
import PluginCard from '@/components/cards/PluginCard.vue'
|
import PluginCard from '@/components/cards/PluginCard.vue'
|
||||||
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav'
|
||||||
|
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||||
import { renderWithProviders } from '@tests/support/render'
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
import { fireEvent, screen, waitFor } from '@testing-library/vue'
|
||||||
import { defineComponent } from 'vue'
|
import { defineComponent } from 'vue'
|
||||||
@@ -34,8 +35,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
|||||||
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
openSharedDialog: (...args: unknown[]) => mocks.openSharedDialog(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
getCardAccentRgbFromImage: mocks.accentFromImage,
|
extractDominantColor: mocks.accentFromImage,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
@@ -55,12 +56,13 @@ const plugin: Plugin = {
|
|||||||
const ImageStub = defineComponent({
|
const ImageStub = defineComponent({
|
||||||
name: 'VImg',
|
name: 'VImg',
|
||||||
emits: ['error', 'load'],
|
emits: ['error', 'load'],
|
||||||
template: '<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')" />',
|
template:
|
||||||
|
'<button data-testid="plugin-image" @click="$emit(\'load\')" @contextmenu.prevent="$emit(\'error\')"><img /></button>',
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('PluginCard lifecycle actions', () => {
|
describe('PluginCard lifecycle actions', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks.accentFromImage.mockReset().mockResolvedValue('12, 34, 56')
|
mocks.accentFromImage.mockReset().mockResolvedValue('#123456')
|
||||||
mocks.apiDelete.mockReset()
|
mocks.apiDelete.mockReset()
|
||||||
mocks.apiGet.mockReset()
|
mocks.apiGet.mockReset()
|
||||||
mocks.apiPost.mockReset()
|
mocks.apiPost.mockReset()
|
||||||
@@ -362,6 +364,7 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
it('handles image lifecycle and ignores card clicks while sorting', async () => {
|
||||||
|
mocks.accentFromImage.mockResolvedValueOnce(undefined).mockResolvedValueOnce('#123456')
|
||||||
const { container } = await renderWithProviders(PluginCard, {
|
const { container } = await renderWithProviders(PluginCard, {
|
||||||
props: {
|
props: {
|
||||||
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
plugin: { ...plugin, plugin_icon: 'https://example.com/plugin.png' },
|
||||||
@@ -372,7 +375,19 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
const [image, authorImage] = screen.getAllByTestId('plugin-image')
|
||||||
await fireEvent.click(image)
|
await fireEvent.click(image)
|
||||||
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalled())
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe('')
|
||||||
await fireEvent.contextMenu(image)
|
await fireEvent.contextMenu(image)
|
||||||
|
await fireEvent.click(image)
|
||||||
|
await waitFor(() => expect(mocks.accentFromImage).toHaveBeenCalledTimes(2))
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe(normalizePluginAccentColor('#123456')?.rgb)
|
||||||
|
await fireEvent.contextMenu(image)
|
||||||
|
expect(
|
||||||
|
container.querySelector<HTMLElement>('.plugin-card')?.style.getPropertyValue('--plugin-card-accent-rgb'),
|
||||||
|
).toBe('')
|
||||||
await fireEvent.click(authorImage)
|
await fireEvent.click(authorImage)
|
||||||
await fireEvent.click(container.querySelector('.v-card')!)
|
await fireEvent.click(container.querySelector('.v-card')!)
|
||||||
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ vi.mock('@/composables/useSharedDialog', () => ({
|
|||||||
openSharedDialog: mocks.openSharedDialog,
|
openSharedDialog: mocks.openSharedDialog,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
extractDominantColor: vi.fn().mockResolvedValue(undefined),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('vue-toastification', () => ({
|
vi.mock('vue-toastification', () => ({
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCardAccentRgbFromImage, useCardAccentColor } from '@/composables/useCardAccentColor'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getDominantColor: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
|
getDominantColor: mocks.getDominantColor,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('useCardAccentColor compatibility', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getDominantColor.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['#FFB400', '255, 180, 0'],
|
||||||
|
['#56CA00', '86, 202, 0'],
|
||||||
|
])('preserves the caller fallback %s', async (fallback, expectedRgb) => {
|
||||||
|
mocks.getDominantColor.mockResolvedValue(fallback)
|
||||||
|
|
||||||
|
await expect(getCardAccentRgbFromImage(null, fallback)).resolves.toBe(expectedRgb)
|
||||||
|
expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the composable fallback scoped to its caller', async () => {
|
||||||
|
mocks.getDominantColor.mockResolvedValue('#8D51F9')
|
||||||
|
const accent = useCardAccentColor('#8D51F9')
|
||||||
|
accent.imageRef.value = { $el: document.createElement('div') }
|
||||||
|
|
||||||
|
await accent.updateAccentColor()
|
||||||
|
|
||||||
|
expect(accent.accentRgb.value).toBe('141, 81, 249')
|
||||||
|
expect(mocks.getDominantColor).toHaveBeenCalledWith(null, { fallback: '#8D51F9' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
|
||||||
|
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
extractDominantColor: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
|
extractDominantColor: mocks.extractDominantColor,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createImageHost() {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.append(document.createElement('img'))
|
||||||
|
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void
|
||||||
|
const promise = new Promise<T>(resolvePromise => {
|
||||||
|
resolve = resolvePromise
|
||||||
|
})
|
||||||
|
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usePluginCardAccent', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.extractDominantColor.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publishes a normalized nullable accent for the plugin card CSS variable', async () => {
|
||||||
|
mocks.extractDominantColor.mockResolvedValue('#ff0000')
|
||||||
|
const accent = usePluginCardAccent()
|
||||||
|
accent.imageRef.value = { $el: createImageHost() }
|
||||||
|
|
||||||
|
await accent.updateAccentColor()
|
||||||
|
|
||||||
|
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#ff0000')?.rgb)
|
||||||
|
expect(accent.accentStyle.value).toEqual({ '--plugin-card-accent-rgb': accent.accentRgb.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the accent unset when extraction fails and can clear a previous value', async () => {
|
||||||
|
const accent = usePluginCardAccent()
|
||||||
|
accent.imageRef.value = { $el: createImageHost() }
|
||||||
|
mocks.extractDominantColor.mockResolvedValueOnce('#00ff00')
|
||||||
|
await accent.updateAccentColor()
|
||||||
|
expect(accent.accentRgb.value).toBeDefined()
|
||||||
|
|
||||||
|
accent.resetAccentColor()
|
||||||
|
expect(accent.accentRgb.value).toBeUndefined()
|
||||||
|
expect(accent.accentStyle.value).toBeUndefined()
|
||||||
|
|
||||||
|
mocks.extractDominantColor.mockResolvedValueOnce(undefined)
|
||||||
|
await accent.updateAccentColor()
|
||||||
|
expect(accent.accentRgb.value).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores an older extraction that resolves after the current logo', async () => {
|
||||||
|
const older = deferred<string | undefined>()
|
||||||
|
const current = deferred<string | undefined>()
|
||||||
|
mocks.extractDominantColor.mockReturnValueOnce(older.promise).mockReturnValueOnce(current.promise)
|
||||||
|
const accent = usePluginCardAccent()
|
||||||
|
accent.imageRef.value = { $el: createImageHost() }
|
||||||
|
|
||||||
|
const olderUpdate = accent.updateAccentColor()
|
||||||
|
const currentUpdate = accent.updateAccentColor()
|
||||||
|
current.resolve('#00ff00')
|
||||||
|
await currentUpdate
|
||||||
|
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb)
|
||||||
|
|
||||||
|
older.resolve('#ff0000')
|
||||||
|
await olderUpdate
|
||||||
|
expect(accent.accentRgb.value).toBe(normalizePluginAccentColor('#00ff00')?.rgb)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the CSS fallback after reset when an extraction resolves late', async () => {
|
||||||
|
const pending = deferred<string | undefined>()
|
||||||
|
mocks.extractDominantColor.mockReturnValueOnce(pending.promise)
|
||||||
|
const accent = usePluginCardAccent()
|
||||||
|
accent.imageRef.value = { $el: createImageHost() }
|
||||||
|
|
||||||
|
const update = accent.updateAccentColor()
|
||||||
|
accent.resetAccentColor()
|
||||||
|
pending.resolve('#ff0000')
|
||||||
|
await update
|
||||||
|
|
||||||
|
expect(accent.accentRgb.value).toBeUndefined()
|
||||||
|
expect(accent.accentStyle.value).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { extractDominantColor } from '@/@core/utils/image'
|
||||||
|
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||||
|
|
||||||
|
/** 管理插件卡 Logo 的可选品牌强调色;无有效提色时由 CSS 环境色接管。 */
|
||||||
|
export function usePluginCardAccent() {
|
||||||
|
const accentRgb = ref<string>()
|
||||||
|
const imageRef = ref<{ $el: HTMLElement } | null>(null)
|
||||||
|
const accentStyle = computed(() => (accentRgb.value ? { '--plugin-card-accent-rgb': accentRgb.value } : undefined))
|
||||||
|
let requestGeneration = 0
|
||||||
|
|
||||||
|
async function updateAccentColor() {
|
||||||
|
const generation = ++requestGeneration
|
||||||
|
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement | undefined
|
||||||
|
const dominantColor = await extractDominantColor(imageElement)
|
||||||
|
if (generation !== requestGeneration) return
|
||||||
|
|
||||||
|
accentRgb.value = dominantColor ? normalizePluginAccentColor(dominantColor)?.rgb : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAccentColor() {
|
||||||
|
requestGeneration += 1
|
||||||
|
accentRgb.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
accentRgb,
|
||||||
|
accentStyle,
|
||||||
|
imageRef,
|
||||||
|
resetAccentColor,
|
||||||
|
updateAccentColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { cwd } from 'node:process'
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
describe('plugin card accent styles', () => {
|
describe('plugin card accent styles', () => {
|
||||||
it('inherits the icon-derived accent instead of shadowing it on the banner', () => {
|
it('uses a nullable icon accent before the dynamic material and theme fallbacks', () => {
|
||||||
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
|
||||||
const ruleStart = commonStyles.indexOf('.plugin-card__banner')
|
const ruleStart = commonStyles.indexOf('.plugin-card__banner')
|
||||||
const ruleEnd = commonStyles.indexOf('.grid-downloading-card', ruleStart)
|
const ruleEnd = commonStyles.indexOf('.grid-downloading-card', ruleStart)
|
||||||
@@ -12,7 +12,17 @@ describe('plugin card accent styles', () => {
|
|||||||
|
|
||||||
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
expect(ruleStart).toBeGreaterThanOrEqual(0)
|
||||||
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
expect(ruleEnd).toBeGreaterThan(ruleStart)
|
||||||
expect(bannerRule).not.toMatch(/--plugin-card-accent-rgb\s*:/)
|
expect(bannerRule).toContain('--plugin-card-effective-accent-rgb: var(')
|
||||||
expect(bannerRule).toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
|
expect(bannerRule).toContain('--plugin-card-accent-rgb,')
|
||||||
|
expect(bannerRule).toContain('var(--glass-material-accent-rgb, var(--v-theme-primary))')
|
||||||
|
expect(bannerRule).not.toContain('40, 169, 225')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits tinted theme mixing to six percent', () => {
|
||||||
|
const glassStyles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
|
||||||
|
|
||||||
|
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)/g)).toHaveLength(9)
|
||||||
|
expect(glassStyles.match(/rgba\(var\(--plugin-card-effective-accent-rgb\)[\s\S]*?\) 94%/g)).toHaveLength(2)
|
||||||
|
expect(glassStyles).not.toContain('var(--plugin-card-accent-rgb, 40, 169, 225)')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1350,10 +1350,14 @@ html[data-theme='transparent'].transparent-glass-realtime .v-theme--transparent
|
|||||||
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
background-image: var(--plugin-card-banner-scrim), var(--plugin-card-banner-tint);
|
||||||
transition: background-image 0.2s ease;
|
transition: background-image 0.2s ease;
|
||||||
|
|
||||||
|
--plugin-card-effective-accent-rgb: var(
|
||||||
|
--plugin-card-accent-rgb,
|
||||||
|
var(--glass-material-accent-rgb, var(--v-theme-primary))
|
||||||
|
);
|
||||||
--plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
|
--plugin-card-banner-scrim: linear-gradient(rgba(0, 0, 0, 60%) 0%, rgba(0, 0, 0, 50%) 100%);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 0%,
|
rgb(var(--plugin-card-effective-accent-rgb)) 0%,
|
||||||
rgb(var(--plugin-card-accent-rgb, 40, 169, 225)) 100%
|
rgb(var(--plugin-card-effective-accent-rgb)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1037,9 +1037,9 @@ html[data-theme='glass'] {
|
|||||||
);
|
);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.2 + var(--glass-tint-density, 0.65) * 0.34)) 0%,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.11 + var(--glass-tint-density, 0.65) * 0.2)) 58%,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.055 + var(--glass-tint-density, 0.65) * 0.1)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1065,9 +1065,9 @@ html[data-theme='glass'] {
|
|||||||
);
|
);
|
||||||
--plugin-card-banner-tint: linear-gradient(
|
--plugin-card-banner-tint: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.15 + var(--glass-tint-density, 0.65) * 0.2)) 0%,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.13)) 58%,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.03 + var(--glass-tint-density, 0.65) * 0.06)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1101,17 +1101,17 @@ html[data-theme='glass'] {
|
|||||||
135deg,
|
135deg,
|
||||||
color-mix(
|
color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 74%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33)) 94%,
|
||||||
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
|
rgba(var(--v-theme-primary), calc(0.14 + var(--glass-tint-density, 0.65) * 0.33))
|
||||||
)
|
)
|
||||||
0%,
|
0%,
|
||||||
color-mix(
|
color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 74%,
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2)) 94%,
|
||||||
rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
|
rgba(var(--v-theme-primary), calc(0.07 + var(--glass-tint-density, 0.65) * 0.2))
|
||||||
)
|
)
|
||||||
58%,
|
58%,
|
||||||
rgba(var(--plugin-card-accent-rgb, 40, 169, 225), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
rgba(var(--plugin-card-effective-accent-rgb), calc(0.04 + var(--glass-tint-density, 0.65) * 0.095)) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { normalizePluginAccentColor } from '@/utils/glassColor'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
interface OklchColor {
|
||||||
|
lightness: number
|
||||||
|
chroma: number
|
||||||
|
hue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbToLinear(channel: number) {
|
||||||
|
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToOklch(color: string): OklchColor {
|
||||||
|
const channels = [color.slice(1, 3), color.slice(3, 5), color.slice(5, 7)].map(channel =>
|
||||||
|
srgbToLinear(Number.parseInt(channel, 16) / 255),
|
||||||
|
)
|
||||||
|
const [red, green, blue] = channels
|
||||||
|
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue)
|
||||||
|
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue)
|
||||||
|
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue)
|
||||||
|
const lightness = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s
|
||||||
|
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s
|
||||||
|
const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
||||||
|
|
||||||
|
return { lightness, chroma: Math.hypot(a, b), hue: Math.atan2(b, a) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueDistanceDegrees(first: number, second: number) {
|
||||||
|
const radians = Math.abs(Math.atan2(Math.sin(first - second), Math.cos(first - second)))
|
||||||
|
|
||||||
|
return (radians * 180) / Math.PI
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizePluginAccentColor', () => {
|
||||||
|
it.each(['#ff0000', '#00ff00', '#0000ff', '#00ffff', '#ff00ff', '#f5c400'])(
|
||||||
|
'keeps chromatic %s within the plugin accent contract',
|
||||||
|
sourceHex => {
|
||||||
|
const normalized = normalizePluginAccentColor(sourceHex)
|
||||||
|
|
||||||
|
expect(normalized).toBeDefined()
|
||||||
|
expect(normalized?.hex).toMatch(/^#[0-9a-f]{6}$/)
|
||||||
|
expect(normalized?.rgb).toMatch(/^\d{1,3}, \d{1,3}, \d{1,3}$/)
|
||||||
|
|
||||||
|
const source = hexToOklch(sourceHex)
|
||||||
|
const output = hexToOklch(normalized!.hex)
|
||||||
|
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||||
|
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||||
|
expect(output.chroma).toBeLessThanOrEqual(Math.min(source.chroma, 0.18) + 0.005)
|
||||||
|
if (source.chroma >= 0.03 && output.chroma >= 0.03)
|
||||||
|
expect(hueDistanceDegrees(source.hue, output.hue)).toBeLessThanOrEqual(2)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(['#000000', '#ffffff', '#7d7d7d'])('keeps neutral %s neutral without inventing chroma', sourceHex => {
|
||||||
|
const normalized = normalizePluginAccentColor(sourceHex)
|
||||||
|
const source = hexToOklch(sourceHex)
|
||||||
|
const output = hexToOklch(normalized!.hex)
|
||||||
|
|
||||||
|
expect(output.lightness).toBeGreaterThanOrEqual(0.475)
|
||||||
|
expect(output.lightness).toBeLessThanOrEqual(0.765)
|
||||||
|
expect(output.chroma).toBeLessThanOrEqual(source.chroma + 0.003)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is deterministic, case-insensitive, and rejects non-contract inputs', () => {
|
||||||
|
expect(normalizePluginAccentColor('#12ABef')).toEqual(normalizePluginAccentColor('#12abef'))
|
||||||
|
expect(normalizePluginAccentColor('#fff')).toBeUndefined()
|
||||||
|
expect(normalizePluginAccentColor('12abef')).toBeUndefined()
|
||||||
|
expect(normalizePluginAccentColor('#gg0000')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
interface OklabColor {
|
||||||
|
lightness: number
|
||||||
|
a: number
|
||||||
|
b: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OklchColor {
|
||||||
|
lightness: number
|
||||||
|
chroma: number
|
||||||
|
hue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlassAccentColor {
|
||||||
|
/** 可传给颜色输入或 WebGL 的六位十六进制色值。 */
|
||||||
|
hex: string
|
||||||
|
/** 可直接用于 `rgb()` / `rgba()` CSS 变量的通道值。 */
|
||||||
|
rgb: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUGIN_ACCENT_MIN_LIGHTNESS = 0.48
|
||||||
|
const PLUGIN_ACCENT_MAX_LIGHTNESS = 0.76
|
||||||
|
const PLUGIN_ACCENT_MAX_CHROMA = 0.18
|
||||||
|
const NEUTRAL_CHROMA_THRESHOLD = 0.02
|
||||||
|
const GAMUT_SEARCH_ITERATIONS = 24
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(color: string) {
|
||||||
|
if (!/^#[0-9a-f]{6}$/i.test(color)) return undefined
|
||||||
|
|
||||||
|
return [
|
||||||
|
Number.parseInt(color.slice(1, 3), 16) / 255,
|
||||||
|
Number.parseInt(color.slice(3, 5), 16) / 255,
|
||||||
|
Number.parseInt(color.slice(5, 7), 16) / 255,
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbToLinear(channel: number) {
|
||||||
|
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearToSrgb(channel: number) {
|
||||||
|
return channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
function srgbToOklab([red, green, blue]: readonly [number, number, number]): OklabColor {
|
||||||
|
const linearRed = srgbToLinear(red)
|
||||||
|
const linearGreen = srgbToLinear(green)
|
||||||
|
const linearBlue = srgbToLinear(blue)
|
||||||
|
const l = Math.cbrt(0.4122214708 * linearRed + 0.5363325363 * linearGreen + 0.0514459929 * linearBlue)
|
||||||
|
const m = Math.cbrt(0.2119034982 * linearRed + 0.6806995451 * linearGreen + 0.1073969566 * linearBlue)
|
||||||
|
const s = Math.cbrt(0.0883024619 * linearRed + 0.2817188376 * linearGreen + 0.6299787005 * linearBlue)
|
||||||
|
|
||||||
|
return {
|
||||||
|
lightness: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||||
|
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||||
|
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklabToLinearSrgb({ lightness, a, b }: OklabColor) {
|
||||||
|
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3
|
||||||
|
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3
|
||||||
|
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3
|
||||||
|
|
||||||
|
return [
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklabToOklch({ lightness, a, b }: OklabColor): OklchColor {
|
||||||
|
return {
|
||||||
|
lightness,
|
||||||
|
chroma: Math.hypot(a, b),
|
||||||
|
hue: Math.atan2(b, a),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklchToOklab({ lightness, chroma, hue }: OklchColor): OklabColor {
|
||||||
|
return {
|
||||||
|
lightness,
|
||||||
|
a: chroma * Math.cos(hue),
|
||||||
|
b: chroma * Math.sin(hue),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInSrgbGamut(color: OklchColor) {
|
||||||
|
return oklabToLinearSrgb(oklchToOklab(color)).every(channel => channel >= 0 && channel <= 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChromaToSrgb(color: OklchColor): OklchColor {
|
||||||
|
if (isInSrgbGamut(color)) return color
|
||||||
|
|
||||||
|
let lowerChroma = 0
|
||||||
|
let upperChroma = color.chroma
|
||||||
|
for (let iteration = 0; iteration < GAMUT_SEARCH_ITERATIONS; iteration += 1) {
|
||||||
|
const candidateChroma = (lowerChroma + upperChroma) / 2
|
||||||
|
if (isInSrgbGamut({ ...color, chroma: candidateChroma })) lowerChroma = candidateChroma
|
||||||
|
else upperChroma = candidateChroma
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...color, chroma: lowerChroma }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAccentColor(color: OklchColor): GlassAccentColor {
|
||||||
|
const channels = oklabToLinearSrgb(oklchToOklab(color)).map(channel =>
|
||||||
|
Math.round(clamp(linearToSrgb(channel), 0, 1) * 255),
|
||||||
|
) as [number, number, number]
|
||||||
|
|
||||||
|
return {
|
||||||
|
hex: `#${channels.map(channel => channel.toString(16).padStart(2, '0')).join('')}`,
|
||||||
|
rgb: channels.join(', '),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将插件 Logo 主色限制在可读范围内,同时保持品牌色相与中性色属性。 */
|
||||||
|
export function normalizePluginAccentColor(color: string): GlassAccentColor | undefined {
|
||||||
|
const srgb = parseHexColor(color)
|
||||||
|
if (!srgb) return undefined
|
||||||
|
|
||||||
|
const source = oklabToOklch(srgbToOklab(srgb))
|
||||||
|
const chroma =
|
||||||
|
source.chroma < NEUTRAL_CHROMA_THRESHOLD ? source.chroma : Math.min(source.chroma, PLUGIN_ACCENT_MAX_CHROMA)
|
||||||
|
const normalized = mapChromaToSrgb({
|
||||||
|
lightness: clamp(source.lightness, PLUGIN_ACCENT_MIN_LIGHTNESS, PLUGIN_ACCENT_MAX_LIGHTNESS),
|
||||||
|
chroma,
|
||||||
|
hue: source.hue,
|
||||||
|
})
|
||||||
|
|
||||||
|
return formatAccentColor(normalized)
|
||||||
|
}
|
||||||
@@ -53,8 +53,8 @@ vi.mock('@/composables/useKeepAliveRefresh', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useCardAccentColor', () => ({
|
vi.mock('@/@core/utils/image', () => ({
|
||||||
getCardAccentRgbFromImage: vi.fn().mockResolvedValue('40, 169, 225'),
|
extractDominantColor: vi.fn().mockResolvedValue(undefined),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/usePWA', () => ({
|
vi.mock('@/composables/usePWA', () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user