Skip to content

Commit 562ebfa

Browse files
authored
ntp: improved background fades (#1404)
1 parent 50cf08b commit 562ebfa

File tree

2 files changed

+126
-53
lines changed

2 files changed

+126
-53
lines changed

special-pages/pages/new-tab/app/components/BackgroundProvider.js

Lines changed: 113 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { Fragment, h } from 'preact';
2-
import cn from 'classnames';
32
import styles from './BackgroundReceiver.module.css';
43
import { values } from '../customizer/values.js';
5-
import { useContext, useState } from 'preact/hooks';
4+
import { useContext, useEffect, useState } from 'preact/hooks';
65
import { CustomizerContext } from '../customizer/CustomizerProvider.js';
76
import { detectThemeFromHex } from '../customizer/utils.js';
87
import { useSignalEffect } from '@preact/signals';
8+
import { memo } from 'preact/compat';
99

1010
/**
1111
* @import { BackgroundVariant, BrowserTheme } from "../../types/new-tab"
@@ -110,7 +110,7 @@ export function BackgroundConsumer({ browser }) {
110110
const gradient = values.gradients[background.value];
111111
return (
112112
<Fragment>
113-
<ImageCrossFade src={gradient.path}></ImageCrossFade>
113+
<ImageCrossFade src={gradient.path} />
114114
<div
115115
className={styles.root}
116116
style={{
@@ -130,51 +130,118 @@ export function BackgroundConsumer({ browser }) {
130130
}
131131
}
132132

133+
/**
134+
* @typedef {'idle'
135+
* | 'loadingFirst'
136+
* | 'loading'
137+
* | 'fading'
138+
* | 'settled'
139+
* } ImgState
140+
*/
141+
142+
/**
143+
* @type {Record<ImgState, ImgState>}
144+
*/
145+
const states = {
146+
idle: 'idle',
147+
loadingFirst: 'loadingFirst',
148+
loading: 'loading',
149+
fading: 'fading',
150+
settled: 'settled',
151+
};
152+
133153
/**
134154
* @param {object} props
135155
* @param {string} props.src
136156
*/
137-
function ImageCrossFade({ src }) {
138-
/**
139-
* Proxy the image source, so that we can keep the old
140-
* image around whilst the new one is loading.
141-
*/
142-
const [stable, setStable] = useState(src);
143-
/**
144-
* Trigger the animation:
145-
*
146-
* NOTE: this animation is deliberately NOT done purely with CSS-triggered state.
147-
* Whilst debugging in WebKit, I found the technique below to be 100% reliable
148-
* in terms of fading a new image over the top of an existing one.
149-
*
150-
* If you find a better way, please test in webkit-based browsers
151-
*/
152-
return (
153-
<Fragment>
154-
<img src={stable} class={styles.root} style={{ display: src === stable ? 'none' : 'block' }} />
155-
<img
156-
src={src}
157-
class={cn(styles.root, styles.over)}
158-
onLoad={(e) => {
159-
const elem = /** @type {HTMLImageElement} */ (e.target);
160-
161-
// HACK: This is what I needed to force, to get 100% predictability. 🤷
162-
elem.style.opacity = '0';
163-
164-
const anim = elem.animate([{ opacity: '0' }, { opacity: '1' }], {
165-
duration: 250,
166-
iterations: 1,
167-
easing: 'ease-in-out',
168-
fill: 'both',
169-
});
170-
171-
// when the fade completes, we want to reset the stable `src`.
172-
// This allows the image underneath to be updated but also allows us to un-mount the fader on top.
173-
anim.onfinish = () => {
174-
setStable(src);
175-
};
176-
}}
177-
/>
178-
</Fragment>
179-
);
157+
function ImageCrossFade_({ src }) {
158+
const [state, setState] = useState({
159+
/** @type {ImgState} */
160+
value: states.idle,
161+
current: src,
162+
next: src,
163+
});
164+
165+
useEffect(() => {
166+
/** @type {HTMLImageElement|undefined} */
167+
let img = new Image();
168+
let cancelled = false;
169+
170+
// Mark the component as being in a 'loading' state, without
171+
// explicit changes to any DOM
172+
setState((prev) => {
173+
// prettier-ignore
174+
const nextState = prev.value === states.idle
175+
? states.loadingFirst
176+
: states.loading
177+
return { ...prev, value: nextState };
178+
});
179+
180+
/** @type {(()=>void)|undefined} */
181+
let handler = () => {
182+
if (cancelled) return;
183+
setState((prev) => {
184+
// when coming from a 'loading' states, we can fade
185+
if (prev.value === states.loading) {
186+
return { ...prev, value: states.fading, next: src };
187+
}
188+
return prev;
189+
});
190+
};
191+
192+
// trigger the load in memory, not on screen
193+
img.addEventListener('load', handler);
194+
img.src = src;
195+
196+
return () => {
197+
cancelled = true;
198+
if (img && handler) {
199+
img.removeEventListener('load', handler);
200+
img = undefined;
201+
handler = undefined;
202+
}
203+
};
204+
}, [src]);
205+
206+
switch (state.value) {
207+
case states.settled:
208+
case states.loadingFirst:
209+
return <img class={styles.root} data-state={state.value} src={state.current} alt="" />;
210+
case states.loading:
211+
case states.fading:
212+
return (
213+
<Fragment>
214+
<img class={styles.root} data-state={state.value} src={state.current} alt="" />
215+
<img
216+
class={styles.root}
217+
data-state={state.value}
218+
src={state.next}
219+
onLoad={(e) => {
220+
const elem = /** @type {HTMLImageElement} */ (e.target);
221+
222+
// HACK: This is what I needed to force, to get 100% predictability. 🤷
223+
elem.style.opacity = '0';
224+
225+
const anim = elem.animate([{ opacity: '0' }, { opacity: '1' }], {
226+
duration: 250,
227+
iterations: 1,
228+
fill: 'both',
229+
});
230+
231+
// when the fade completes, we want to reset the stable `src`.
232+
// This allows the image underneath to be updated but also allows us to un-mount the fader on top.
233+
anim.onfinish = () => {
234+
setState((prev) => {
235+
return { ...prev, value: states.settled, current: prev.next, next: prev.next };
236+
});
237+
};
238+
}}
239+
/>
240+
</Fragment>
241+
);
242+
default:
243+
return null;
244+
}
180245
}
246+
247+
const ImageCrossFade = memo(ImageCrossFade_);

special-pages/pages/new-tab/app/components/BackgroundReceiver.module.css

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,20 @@
77
object-fit: cover;
88
pointer-events: none;
99

10-
&[data-animate="true"] {
11-
transition: background .3s;
10+
&[data-state="loadingFirst"] {
11+
animation-name: fade-in;
12+
animation-fill-mode: both;
13+
animation-duration: .25s;
14+
animation-iteration-count: 1;
1215
}
1316
}
1417

15-
.under {
16-
opacity: 1;
17-
}
18-
.over {
19-
opacity: 0;
18+
@keyframes fade-in {
19+
from {
20+
opacity: 0;
21+
}
22+
to {
23+
opacity: 1;
24+
}
2025
}
26+

0 commit comments

Comments
 (0)