feat(display): background photo groups with blurred, compressed and original versions; veil, sway and blur options in the appearance panel [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful

This commit is contained in:
justme committed 2026-10-09 04:50:11 +08:00
1 parent 725fe17a7d
commit 044ce0c339
34 files changed
+255 -33

No files matched your search

+32 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { APPEARANCE_DEFAULTS, APPEARANCE_RANGES, resetAppearance, setAppearance, useAppearance, type AppearanceSettings } from './appearance'
import { PHOTO_GROUPS, PHOTOS } from './backgrounds'
type Tab = 'weather' | 'background'
@@ -8,6 +9,8 @@ type SliderName = keyof typeof APPEARANCE_RANGES
interface Slider {
name: SliderName
label: string
/** 悬停提示。 */
hint?: string
/** 数值怎么显示:默认是百分比。 */
format?: (value: number) => string
}
@@ -29,17 +32,18 @@ const WEATHER_SWITCHES: { name: 'mouse' | 'bullet'; label: string; hint: string
]
const BACKGROUND_SLIDERS: Slider[] = [
{ name: 'bgBlur', label: '模糊', format: (value) => `${value}px` },
{ name: 'bgBlur', label: '再模糊', format: (value) => `${value}px` },
{ name: 'parallax', label: '晃动' },
{ name: 'bgVeil', label: '遮罩', hint: '盖在背景图上的那层灰色(暗色)/ 白色(亮色),0% 就是不盖' },
]
const WEATHER_KEYS: (keyof AppearanceSettings)[] = ['enabled', 'density', 'speed', 'wind', 'size', 'blur', 'opacity', 'bullet', 'mouse']
const BACKGROUND_KEYS: (keyof AppearanceSettings)[] = ['bgBlur', 'parallax']
const BACKGROUND_KEYS: (keyof AppearanceSettings)[] = ['photo', 'bgBlurred', 'bgBlur', 'bgOriginal', 'sway', 'parallax', 'bgVeil']
function SliderRow({ slider, settings, disabled }: { slider: Slider; settings: AppearanceSettings; disabled?: boolean }) {
const { name, label, format = percent } = slider
const { name, label, hint, format = percent } = slider
return (
<label className="appearance-row">
<label className="appearance-row" title={hint}>
<span>{label}</span>
<input type="range" {...APPEARANCE_RANGES[name]} value={settings[name]} disabled={disabled} onChange={(event) => setAppearance({ [name]: Number(event.target.value) })} />
<output>{format(settings[name])}</output>
@@ -111,9 +115,31 @@ export function AppearancePanel() {
</div>
) : (
<div className="appearance-body" key="background">
{BACKGROUND_SLIDERS.map((slider) => (
<SliderRow key={slider.name} slider={slider} settings={settings} />
{PHOTO_GROUPS.map((group) => (
<div key={group} role="group" aria-label={group}>
<div className="appearance-group-title">{group}</div>
<div className="appearance-choices">
{PHOTOS.filter((photo) => photo.group === group).map(({ id, label, light }) => (
<button key={id} type="button" className="appearance-choice" aria-pressed={settings.photo === id} onClick={() => setAppearance({ photo: id })}>
<img src={light.blur} alt="" loading="lazy" />
{label}
</button>
))}
</div>
</div>
))}
<SwitchRow label="背景模糊" hint="关掉就换成不模糊的图" checked={settings.bgBlurred} onChange={(bgBlurred) => setAppearance({ bgBlurred })} />
<SliderRow slider={BACKGROUND_SLIDERS[0]} settings={settings} disabled={!settings.bgBlurred} />
<SwitchRow
label="不压缩"
hint="背景模糊关着时:开是加载没压缩的原图(每张约 2MB),关是压缩过的(不到 300KB)"
checked={settings.bgOriginal}
disabled={settings.bgBlurred}
onChange={(bgOriginal) => setAppearance({ bgOriginal })}
/>
<SwitchRow label="背景晃动" hint="关掉就不跟着鼠标晃" checked={settings.sway} onChange={(sway) => setAppearance({ sway })} />
<SliderRow slider={BACKGROUND_SLIDERS[1]} settings={settings} disabled={!settings.sway} />
<SliderRow slider={BACKGROUND_SLIDERS[2]} settings={settings} />
</div>
)}
<button type="button" className="appearance-reset" disabled={!changed} tabIndex={open ? 0 : -1} onClick={() => resetAppearance(keys)}>
+41 -7
View File
@@ -1,4 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react'
import { backgroundUrl, DEFAULT_PHOTO, PHOTOS } from './backgrounds'
/**
* 展示页右下角「外观」里能调的参数:都是相对默认值的倍数(1 = 原样),存在浏览器里。
@@ -23,10 +24,20 @@ export interface AppearanceSettings {
bullet: boolean
/** 鼠标拨动雪。 */
mouse: boolean
/** 背景在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */
/** 用哪张照片(id,见 backgrounds.ts)。 */
photo: string
/** 背景模糊:开是随主题的两张预先模糊过的图,关是不模糊的图(两张,也是随主题)。 */
bgBlurred: boolean
/** 背景模糊关着的时候:开是加载没压缩的原图(PNG,近 2MB 一张),关是压缩过的(JPG,不到 300KB)。 */
bgOriginal: boolean
/** 背景模糊开着的时候,在预先模糊的基础上再模糊多少(像素,0 是不再模糊)。 */
bgBlur: number
/** 背景跟着鼠标晃动的幅度(0 是不动,1 是默认)。 */
/** 背景跟着鼠标晃动:总开关,关了背景就完全不动。 */
sway: boolean
/** 背景跟着鼠标晃动的幅度(1 是默认)。 */
parallax: number
/** 盖在背景图上的那层遮罩(亮色是白、暗色是灰)的浓度:0 是不盖,1 是默认(亮色 50%、暗色 62%)。 */
bgVeil: number
}
export const APPEARANCE_DEFAULTS: AppearanceSettings = {
@@ -39,8 +50,13 @@ export const APPEARANCE_DEFAULTS: AppearanceSettings = {
opacity: 1,
bullet: true,
mouse: true,
photo: DEFAULT_PHOTO,
bgBlurred: true,
bgOriginal: false,
bgBlur: 0,
sway: true,
parallax: 1,
bgVeil: 1,
}
/** 每个滑块的范围(和 AppearanceSettings 里同名的数值项对应)。 */
@@ -53,6 +69,7 @@ export const APPEARANCE_RANGES = {
opacity: { min: 0.2, max: 2.5, step: 0.05 },
bgBlur: { min: 0, max: 30, step: 1 },
parallax: { min: 0, max: 1.5, step: 0.05 },
bgVeil: { min: 0, max: 1.6, step: 0.05 },
} as const
const KEY = 'moshiro.appearance'
@@ -64,7 +81,10 @@ function load(): AppearanceSettings {
const merged: Record<string, unknown> = { ...APPEARANCE_DEFAULTS }
for (const name of Object.keys(APPEARANCE_DEFAULTS) as (keyof AppearanceSettings)[]) {
const value = saved[name]
if (typeof value === typeof APPEARANCE_DEFAULTS[name]) merged[name] = value
if (typeof value !== typeof APPEARANCE_DEFAULTS[name]) continue
// 照片只认列表里有的,图删了、改名了就回到默认
if (name === 'photo' && !PHOTOS.some((photo) => photo.id === value)) continue
merged[name] = value
}
return merged as unknown as AppearanceSettings
} catch {
@@ -96,14 +116,28 @@ export function subscribeAppearance(listener: () => void): () => void {
export const useAppearance = () => useSyncExternalStore(subscribeAppearance, getAppearance)
/** 把背景的额外模糊写进 <html> 的 --bg-filter(display.css 里 .bg-layer 用它;不模糊的时候是 none,不给浏览器多一层滤镜)。 */
/**
* 把当前该用的背景图写进 <html> 的 --bg-image(照片、主题、背景模糊、不压缩一起决定用哪个版本;display.css 里 .bg-layer 用它),
* 把遮罩的浓度写进 --bg-veil-scale,把背景的额外模糊写进 --bg-filter(不模糊的时候是 none,不给浏览器多一层滤镜),
* 把晃动的开关写进 data-sway(关了就按窗口的原始比例铺满,display.css 里 data-sway='off')。切换主题时也重新算。
*/
export function useAppearanceVars() {
useEffect(() => {
const root = document.documentElement
const apply = () => {
const blur = getAppearance().bgBlur
document.documentElement.style.setProperty('--bg-filter', blur > 0 ? `blur(${blur}px)` : 'none')
const { bgBlur, bgBlurred, bgOriginal, photo, bgVeil, sway } = getAppearance()
root.style.setProperty('--bg-veil-scale', String(bgVeil))
root.style.setProperty('--bg-filter', bgBlurred && bgBlur > 0 ? `blur(${bgBlur}px)` : 'none')
root.style.setProperty('--bg-image', `url("${backgroundUrl(photo, root.dataset.theme === 'dark', bgBlurred, bgOriginal)}")`)
root.dataset.sway = sway ? 'on' : 'off'
}
apply()
return subscribeAppearance(apply)
const observer = new MutationObserver(apply)
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
const unsubscribe = subscribeAppearance(apply)
return () => {
observer.disconnect()
unsubscribe()
}
}, [])
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

+19 -9
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
import bgLight from './assets/bg.jpg'
import bgDark from './assets/bg-dark.jpg'
import { getAppearance, subscribeAppearance } from './appearance'
import { backgroundUrl } from './backgrounds'
/** 取样的缩略图大小:背景本来就是预先模糊过的,缩得很小就够算平均色。 */
const SAMPLE_WIDTH = 32
@@ -46,8 +46,8 @@ function toHsl(r: number, g: number, b: number): [number, number, number] {
}
/**
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图自己的平均色算出来:
* 色相照搬图里的,饱和度打个折(免得发艳、发蓝),亮度压到很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
* 暗色下玻璃的颜色,和盖在背景图上的底色,都从当前背景图(选了哪组照片、哪个主题)自己的平均色算出来:
* 色相照搬图里的,饱和度保留大半,亮度跟着图本身的亮度走但压得很暗。写进 <html> 的 --bg-tint(玻璃)和 --bg-veil-tint(底色),
* tokens.css 里暗色的 --glass-tint / --glass-veil 用它们;切换主题时重新取。亮色不用这个。
*/
export function useBackgroundTint() {
@@ -58,12 +58,18 @@ export function useBackgroundTint() {
const image = images.get(url)
const average = image && averageColor(image)
if (!average) return
const [h, s] = toHsl(...average)
root.style.setProperty('--bg-tint', `hsl(${h.toFixed(0)} ${(s * 55).toFixed(0)}% 13%)`)
root.style.setProperty('--bg-veil-tint', `hsl(${h.toFixed(0)} ${(s * 45).toFixed(0)}% 8%)`)
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 = root.dataset.theme === 'dark' ? bgDark : bgLight
// 取样用预先模糊的那个版本:和不模糊的原图平均色一样,图小、取得快
const url = backgroundUrl(getAppearance().photo, root.dataset.theme === 'dark', true, false)
if (images.has(url)) {
apply(url)
return
@@ -76,6 +82,10 @@ export function useBackgroundTint() {
update()
const observer = new MutationObserver(update)
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
const unsubscribe = subscribeAppearance(update)
return () => {
observer.disconnect()
unsubscribe()
}
}, [])
}
+81
View File
@@ -0,0 +1,81 @@
import bgBlurLight from './assets/bg.jpg'
import bgBlurDark from './assets/bg-dark.jpg'
import bgSharpLight from './assets/bg-sharp.jpg'
import bgSharpDark from './assets/bg-sharp-dark.jpg'
import bgOriginalLight from './assets/bg-sharp.png'
import bgOriginalDark from './assets/bg-sharp-dark.png'
import banaBlur from './assets/bg-bana.jpg'
import banaSharp from './assets/bg-bana-sharp.jpg'
import banaOriginal from './assets/bg-bana-sharp.png'
import bana2Blur from './assets/bg-bana2.jpg'
import bana2Sharp from './assets/bg-bana2-sharp.jpg'
import bana2Original from './assets/bg-bana2-sharp.png'
import photo86_3Blur from './assets/photo86-3-blur.jpg'
import photo86_3 from './assets/photo86-3.jpg'
import photo86_3Original from './assets/photo86-3-original.png'
import photo86_7Blur from './assets/photo86-7-blur.jpg'
import photo86_7 from './assets/photo86-7.jpg'
import photo86_7Original from './assets/photo86-7-original.jpg'
import photo86_2Blur from './assets/photo86-2-blur.jpg'
import photo86_2 from './assets/photo86-2.jpg'
import photo86_2Original from './assets/photo86-2-original.jpg'
import photo86_8Blur from './assets/photo86-8-blur.jpg'
import photo86_8 from './assets/photo86-8.jpg'
import photo86_8Original from './assets/photo86-8-original.jpg'
import photo86_5Blur from './assets/photo86-5-blur.jpg'
import photo86_5 from './assets/photo86-5.jpg'
import photo86_5Original from './assets/photo86-5-original.jpg'
import photo86_6Blur from './assets/photo86-6-blur.jpg'
import photo86_6 from './assets/photo86-6.jpg'
import photo86_6Original from './assets/photo86-6-original.jpg'
/** 一张照片的三个版本:blur 是预先模糊过的,sharp 是不模糊但压缩过的,original 是没压缩的原图。 */
export interface Variants {
blur: string
sharp: string
original: string
}
/** 可选的背景照片:group 是分组(面板里一组一行),亮色和暗色可以是不同的两张,也可以是同一张。 */
export interface Photo {
id: string
group: string
label: string
light: Variants
dark: Variants
}
const same = (variants: Variants) => ({ light: variants, dark: variants })
/** 加一张照片:把它的三个版本放进 assets/,在这里加一项。第一项是默认的。 */
export const PHOTOS: Photo[] = [
{
id: 'sleep',
group: 'shirobana',
label: '睡颜',
// 亮色睁眼、暗色闭眼
light: { blur: bgBlurLight, sharp: bgSharpLight, original: bgOriginalLight },
dark: { blur: bgBlurDark, sharp: bgSharpDark, original: bgOriginalDark },
},
{ id: 'bana', group: 'shirobana', label: '伸手', ...same({ blur: banaBlur, sharp: banaSharp, original: banaOriginal }) },
{ id: 'bana2', group: 'shirobana', label: '海边', ...same({ blur: bana2Blur, sharp: bana2Sharp, original: bana2Original }) },
{ id: '86-3', group: '86', label: '小憩', ...same({ blur: photo86_3Blur, sharp: photo86_3, original: photo86_3Original }) },
{ id: '86-7', group: '86', label: '窗边', ...same({ blur: photo86_7Blur, sharp: photo86_7, original: photo86_7Original }) },
{ id: '86-2', group: '86', label: '伞下', ...same({ blur: photo86_2Blur, sharp: photo86_2, original: photo86_2Original }) },
{ id: '86-8', group: '86', label: '驾驶', ...same({ blur: photo86_8Blur, sharp: photo86_8, original: photo86_8Original }) },
{ id: '86-5', group: '86', label: '怀中', ...same({ blur: photo86_5Blur, sharp: photo86_5, original: photo86_5Original }) },
{ id: '86-6', group: '86', label: '倚靠', ...same({ blur: photo86_6Blur, sharp: photo86_6, original: photo86_6Original }) },
]
export const DEFAULT_PHOTO = PHOTOS[0].id
/** 照片所在的分组,按出现的顺序。 */
export const PHOTO_GROUPS = [...new Set(PHOTOS.map((photo) => photo.group))]
export const findPhoto = (id: string): Photo => PHOTOS.find((photo) => photo.id === id) ?? PHOTOS[0]
/** 当前该用哪张图:哪张照片、哪个主题,再按「背景模糊」「不压缩」选版本。 */
export function backgroundUrl(id: string, dark: boolean, blurred: boolean, original: boolean): string {
const variants = dark ? findPhoto(id).dark : findPhoto(id).light
return blurred ? variants.blur : original ? variants.original : variants.sharp
}
+66 -9
View File
@@ -1008,7 +1008,7 @@
z-index: -1;
background-image:
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
url('./assets/bg.jpg');
var(--bg-image, url('./assets/bg.jpg'));
background-size: cover;
background-position: center;
pointer-events: none;
@@ -1017,18 +1017,24 @@
}
:root {
--bg-veil: 50%;
/* 盖在背景图上的遮罩的浓度:默认亮色 50%、暗色 62%;右下角「外观」里按倍数调(--bg-veil-scale,0 是不盖) */
--bg-veil: min(100%, calc(50% * var(--bg-veil-scale, 1)));
}
:root[data-theme="dark"] {
--bg-veil: 62%;
--bg-veil: min(100%, calc(62% * var(--bg-veil-scale, 1)));
}
/* 暗色模式换一张背景图 */
/* 用哪张图由「外观」里的选择决定(backgrounds.ts,JS 把它写进 --bg-image);这里的回退只管 JS 还没跑的第一帧:暗色先用暗色那张 */
:root[data-theme="dark"] .bg-layer {
background-image:
linear-gradient(color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent), color-mix(in srgb, var(--glass-veil) var(--bg-veil), transparent)),
url('./assets/bg-dark.jpg');
var(--bg-image, url('./assets/bg-dark.jpg'));
}
/* 关了背景晃动:背景不会再移动,就不用向外多留一圈边距了,按窗口的原始比例铺满(不会被放大裁掉四周) */
:root[data-sway='off'] .bg-layer {
inset: 0;
}
/* ── 亚克力(毛玻璃)规范:所有玻璃面(卡片、线路面板、顶部栏、时间轴)只用下面这组变量;颜色来自 tokens.css 的 --glass-*,换风格改那里 ──
@@ -1313,8 +1319,8 @@ input {
white-space: nowrap;
}
/* 悬浮提示弹在卡片右边,会盖到下一张卡片上:每张卡片都有自己的层叠上下文(毛玻璃),所以悬停(提示打开)的那张要整张抬到兄弟卡片之上 */
.agent-card:hover,
/* 悬浮提示弹在卡片右边,会盖到下一张卡片上:每张卡片都有自己的层叠上下文(毛玻璃),所以提示打开的那张要整张抬到兄弟卡片之上。
只在提示真的打开时才抬,不要鼠标一碰到卡片就抬:z-index 一变,带 backdrop-filter 的整张卡片要重新分层,那一帧底部会闪黑 */
.agent-card:has(.pop.open) {
z-index: 30;
}
@@ -1393,8 +1399,7 @@ input {
height: 100%;
z-index: -1;
pointer-events: none;
/* 滤色:粒子只会把背后的图提亮,边缘羽化的地方自然融进图里 */
mix-blend-mode: screen;
/* 不要用 mix-blend-mode(滤色等):画布在带 backdrop-filter 的卡片后面每帧都在变,再叠一层混合,Chrome 给卡片取背景的时候会整块重新分层,卡片底部就闪黑;边缘的羽化用颜色和透明度做,已经是浅色半透明的,效果和滤色差不多 */
}
@media (max-width: 640px), ((pointer: coarse) and (max-width: 900px)) {
@@ -1657,3 +1662,55 @@ input {
display: none;
}
}
/* 照片的选择:按组分行,每组一个小标题,下面是缩略图,选中的描一圈 */
.appearance-group-title {
margin-bottom: 4px;
color: var(--app-ink-faint);
font-size: 11px;
letter-spacing: 0.04em;
}
.appearance-choices {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.appearance-choice {
display: grid;
gap: 4px;
padding: 0;
border: 0;
color: var(--app-ink-muted);
background: none;
cursor: pointer;
text-align: center;
font-size: 12px;
}
.appearance-choice img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--r-control);
box-shadow: inset 0 0 0 1px var(--glass-ring);
transition:
box-shadow 0.2s,
transform 0.2s;
}
.appearance-choice:hover img {
transform: translateY(-1px);
}
.appearance-choice[aria-pressed='true'] {
color: var(--app-ink);
font-weight: 600;
}
.appearance-choice[aria-pressed='true'] img {
box-shadow:
0 0 0 2px var(--app-primary),
0 0 0 4px color-mix(in srgb, var(--app-primary) 25%, transparent);
}
+16 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, type RefObject } from 'react'
import { getAppearance } from './appearance'
import { getAppearance, subscribeAppearance } from './appearance'
/** 指针在窗口里的位置带来的偏移上限(像素):鼠标停在窗口边缘时背景偏这么多。 */
const POSITION_SHIFT = 30
@@ -53,7 +53,7 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
const clamp = (value: number) => Math.max(-SWAY_MAX, Math.min(SWAY_MAX, value))
const move = (event: PointerEvent) => {
// 位置:指针在窗口里的位置换成 -1 到 1(背景朝同一个方向偏移,想要反方向就把符号取反)
const amount = getAppearance().parallax
const amount = swayAmount()
baseX = ((event.clientX / window.innerWidth) * 2 - 1) * POSITION_SHIFT * amount
baseY = ((event.clientY / window.innerHeight) * 2 - 1) * POSITION_SHIFT * amount
// 晃动:这一次移动了多远,就再推背景一把
@@ -65,6 +65,11 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
lastY = event.clientY
schedule()
}
// 晃动的幅度:总开关关了就是 0
const swayAmount = () => {
const { sway, parallax } = getAppearance()
return sway ? parallax : 0
}
const leave = () => {
baseX = 0
baseY = 0
@@ -75,7 +80,16 @@ export function useBackgroundParallax(layer: RefObject<HTMLElement | null>) {
window.addEventListener('pointermove', move, { passive: true })
document.documentElement.addEventListener('mouseleave', leave)
// 设置里把晃动关了:背景慢慢回到中间,不用等下一次鼠标移动
const unsubscribe = subscribeAppearance(() => {
if (swayAmount() === 0) {
swayX = 0
swayY = 0
leave()
}
})
return () => {
unsubscribe()
window.removeEventListener('pointermove', move)
document.documentElement.removeEventListener('mouseleave', leave)
cancelAnimationFrame(frame)