11import { Fragment , h } from 'preact' ;
2- import cn from 'classnames' ;
32import styles from './BackgroundReceiver.module.css' ;
43import { values } from '../customizer/values.js' ;
5- import { useContext , useState } from 'preact/hooks' ;
4+ import { useContext , useEffect , useState } from 'preact/hooks' ;
65import { CustomizerContext } from '../customizer/CustomizerProvider.js' ;
76import { detectThemeFromHex } from '../customizer/utils.js' ;
87import { 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_ ) ;
0 commit comments