92 lines
4.0 KiB
TypeScript
92 lines
4.0 KiB
TypeScript
import { useEffect } from 'react'
|
||
import { getAppearance, subscribeAppearance } from './appearance'
|
||
import { backgroundUrl } from './backgrounds'
|
||
|
||
/** 取样的缩略图大小:背景本来就是预先模糊过的,缩得很小就够算平均色。 */
|
||
const SAMPLE_WIDTH = 32
|
||
const SAMPLE_HEIGHT = 18
|
||
|
||
/** 一张图的平均颜色(0–255 的 r、g、b),按每个像素的亮度加权过轻:太亮的高光(衣服、头发的亮部)不要把整体拉亮。 */
|
||
function averageColor(image: HTMLImageElement): [number, number, number] | null {
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = SAMPLE_WIDTH
|
||
canvas.height = SAMPLE_HEIGHT
|
||
const context = canvas.getContext('2d', { willReadFrequently: true })
|
||
if (!context) return null
|
||
context.drawImage(image, 0, 0, SAMPLE_WIDTH, SAMPLE_HEIGHT)
|
||
const { data } = context.getImageData(0, 0, SAMPLE_WIDTH, SAMPLE_HEIGHT)
|
||
let r = 0
|
||
let g = 0
|
||
let b = 0
|
||
let weight = 0
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const luma = (data[i] * 0.299 + data[i + 1] * 0.587 + data[i + 2] * 0.114) / 255
|
||
const w = 1 - 0.6 * luma
|
||
r += data[i] * w
|
||
g += data[i + 1] * w
|
||
b += data[i + 2] * w
|
||
weight += w
|
||
}
|
||
return weight > 0 ? [r / weight, g / weight, b / weight] : null
|
||
}
|
||
|
||
/** rgb(0–255)转 hsl(h 0–360,s、l 0–1)。 */
|
||
function toHsl(r: number, g: number, b: number): [number, number, number] {
|
||
const red = r / 255
|
||
const green = g / 255
|
||
const blue = b / 255
|
||
const max = Math.max(red, green, blue)
|
||
const min = Math.min(red, green, blue)
|
||
const l = (max + min) / 2
|
||
if (max === min) return [0, 0, l]
|
||
const d = max - min
|
||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||
const h = max === red ? (green - blue) / d + (green < blue ? 6 : 0) : max === green ? (blue - red) / d + 2 : (red - green) / d + 4
|
||
return [h * 60, s, l]
|
||
}
|
||
|
||
/**
|
||
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图(选了哪组照片、哪个主题)自己的平均色算出来:
|
||
* 色相照搬图里的,饱和度保留大半,亮度跟着图本身的亮度走但压得很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
|
||
* tokens.css 里暗色的 --glass-tint / --glass-veil 用它们;切换主题时重新取。亮色不用这个。
|
||
*/
|
||
export function useBackgroundTint() {
|
||
useEffect(() => {
|
||
const root = document.documentElement
|
||
const images = new Map<string, HTMLImageElement>()
|
||
const apply = (url: string) => {
|
||
const image = images.get(url)
|
||
const average = image && averageColor(image)
|
||
if (!average) return
|
||
const [h, s, l] = toHsl(...average)
|
||
// 玻璃:色相照搬,饱和度保留大半(上限 45%),亮度跟着图本身的亮度走(图越亮,玻璃越亮,10%–22%);
|
||
// 底色(盖在背景图上的遮罩)同理,但更暗(6%–14%)。所以换一张照片,暗色玻璃的颜色和深浅都会跟着变
|
||
const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value))
|
||
const glass = clamp(9 + l * 26, 10, 22)
|
||
const veil = clamp(5 + l * 14, 6, 14)
|
||
root.style.setProperty('--bg-tint', `hsl(${h.toFixed(0)} ${clamp(s * 90, 0, 45).toFixed(0)}% ${glass.toFixed(1)}%)`)
|
||
root.style.setProperty('--bg-veil-tint', `hsl(${h.toFixed(0)} ${clamp(s * 75, 0, 38).toFixed(0)}% ${veil.toFixed(1)}%)`)
|
||
}
|
||
const update = () => {
|
||
// 取样用预先模糊的那个版本:和不模糊的原图平均色一样,图小、取得快
|
||
const url = backgroundUrl(getAppearance().photo, root.dataset.theme === 'dark', true, false)
|
||
if (images.has(url)) {
|
||
apply(url)
|
||
return
|
||
}
|
||
const image = new Image()
|
||
images.set(url, image)
|
||
image.onload = () => apply(url)
|
||
image.src = url
|
||
}
|
||
update()
|
||
const observer = new MutationObserver(update)
|
||
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
||
const unsubscribe = subscribeAppearance(update)
|
||
return () => {
|
||
observer.disconnect()
|
||
unsubscribe()
|
||
}
|
||
}, [])
|
||
}
|