@@ -26,6 +26,92 @@ const getArticleFor = (word) => {
2626 return [ 'a' , 'e' , 'i' , 'o' , 'u' ] . includes ( firstLetter ) ? 'an' : 'a' ;
2727} ;
2828
29+ /**
30+ * The shimmer shown while a panel image is still decoding.
31+ *
32+ * Same treatment the upload path uses for its placeholder, pulled out so a tab
33+ * switch and an upload read as the same "working on it" state.
34+ */
35+ const PanelImageShimmer = ( ) => (
36+ < >
37+ < style > { `@keyframes panelImgShimmer { 0% { transform: translateY(100%); } 100% { transform: translateY(-100%); } }` } </ style >
38+ < div style = { {
39+ position : 'absolute' ,
40+ left : 0 ,
41+ right : 0 ,
42+ height : '60%' ,
43+ background : 'linear-gradient(to top, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)' ,
44+ animation : 'panelImgShimmer 1.2s ease-in-out infinite'
45+ } } />
46+ </ >
47+ ) ;
48+
49+ /**
50+ * A panel image that tracks its own decode state.
51+ *
52+ * Switching right-panel tabs used to show the PREVIOUS node's image for a beat
53+ * before flipping to the new one. Two causes, both handled here:
54+ *
55+ * - React reuses one <img> across tabs and only swaps `src`. The browser keeps
56+ * painting the old frame until the new bytes decode, so the wrong image is
57+ * on screen the whole time. Callers mount this with `key={src}`, so a new
58+ * source is a new element with `isLoaded` false — the stale frame is never
59+ * shown at all.
60+ * - Nothing reserved the image's height, so everything below it jumped once
61+ * the new image landed. The aspect ratio is known for uploads and Wikipedia
62+ * thumbnails alike, so the box is sized before the bytes arrive.
63+ *
64+ * `loading="lazy"` is deliberately absent: it defers the very fetch being
65+ * waited on, which lengthened the blank state it was meant to help.
66+ *
67+ * @param {string } src - Resolved image URL.
68+ * @param {string } alt - Alt text (the node's name).
69+ * @param {number } [aspectRatio] - height/width, as stored on the prototype.
70+ */
71+ const PanelImage = ( { src, alt, aspectRatio } ) => {
72+ const [ isLoaded , setIsLoaded ] = useState ( false ) ;
73+
74+ // An image already in the browser cache can be `complete` before React
75+ // attaches onLoad, and that load event never fires — without this the
76+ // shimmer would sit there forever on a tab you have already visited.
77+ const measureRef = ( node ) => {
78+ if ( node ?. complete && node . naturalWidth > 0 ) setIsLoaded ( true ) ;
79+ } ;
80+
81+ return (
82+ < div style = { {
83+ width : '100%' ,
84+ overflow : 'hidden' ,
85+ borderRadius : '6px' ,
86+ position : 'relative' ,
87+ // Held only until the image can size the box itself.
88+ background : isLoaded ? 'transparent' : '#cfcfcf' ,
89+ aspectRatio : isLoaded ? undefined : ( aspectRatio ? `1 / ${ aspectRatio } ` : '1 / 1' )
90+ } } >
91+ < img
92+ ref = { measureRef }
93+ src = { src }
94+ alt = { alt }
95+ decoding = "async"
96+ // Treat a failed load as settled: a broken image should fall back to
97+ // the empty box, not shimmer indefinitely.
98+ onLoad = { ( ) => setIsLoaded ( true ) }
99+ onError = { ( ) => setIsLoaded ( true ) }
100+ style = { {
101+ display : 'block' ,
102+ width : '100%' ,
103+ height : 'auto' ,
104+ objectFit : 'contain' ,
105+ borderRadius : '6px' ,
106+ opacity : isLoaded ? 1 : 0 ,
107+ transition : 'opacity 0.18s ease'
108+ } }
109+ />
110+ { ! isLoaded && < PanelImageShimmer /> }
111+ </ div >
112+ ) ;
113+ } ;
114+
29115// Wikipedia enrichment functions
30116const searchWikipedia = async ( query ) => {
31117 console . log ( `[Wikipedia Images] 🔎 searchWikipedia called with query: "${ query } "` ) ;
@@ -1657,6 +1743,18 @@ const SharedPanelContent = ({
16571743 { /* Image Section — always visible; shows image or empty state */ }
16581744 { ( ( ) => {
16591745 const hasImage = ! ! ( nodeData . imageSrc || nodeData . semanticMetadata ?. wikipediaOriginalImage || nodeData . semanticMetadata ?. wikipediaThumbnail ) ;
1746+ const resolvedImageSrc =
1747+ nodeData . imageSrc ||
1748+ nodeData . semanticMetadata ?. wikipediaOriginalImage ||
1749+ cachedImage ?. thumbnailSrc ||
1750+ nodeData . semanticMetadata ?. wikipediaThumbnail ;
1751+ // Same precedence as the src above: whichever source wins should size
1752+ // the box, or the reserved space is wrong and the panel still jumps.
1753+ const resolvedAspectRatio =
1754+ nodeData . imageAspectRatio ||
1755+ cachedImage ?. imageAspectRatio ||
1756+ nodeData . semanticMetadata ?. imageAspectRatio ||
1757+ null ;
16601758 return (
16611759 < CollapsibleSection
16621760 title = { (
@@ -1674,30 +1772,16 @@ const SharedPanelContent = ({
16741772 ) : undefined }
16751773 defaultExpanded = { true }
16761774 >
1677- { hasImage && (
1678- < div style = { {
1679- width : '100%' ,
1680- overflow : 'hidden' ,
1681- borderRadius : '6px'
1682- } } >
1683- < img
1684- src = {
1685- nodeData . imageSrc ||
1686- nodeData . semanticMetadata ?. wikipediaOriginalImage ||
1687- cachedImage ?. thumbnailSrc ||
1688- nodeData . semanticMetadata ?. wikipediaThumbnail
1689- }
1690- alt = { nodeData . name }
1691- loading = "lazy"
1692- style = { {
1693- display : 'block' ,
1694- width : '100%' ,
1695- height : 'auto' ,
1696- objectFit : 'contain' ,
1697- borderRadius : '6px'
1698- } }
1699- />
1700- </ div >
1775+ { hasImage && resolvedImageSrc && (
1776+ // Keyed on the src so a different image is a different element:
1777+ // see PanelImage for why reusing one <img> shows the outgoing
1778+ // node's picture until the incoming one decodes.
1779+ < PanelImage
1780+ key = { resolvedImageSrc }
1781+ src = { resolvedImageSrc }
1782+ alt = { nodeData . name }
1783+ aspectRatio = { resolvedAspectRatio }
1784+ />
17011785 ) }
17021786 { ! hasImage && imageLoading && (
17031787 < div style = { {
@@ -1708,15 +1792,7 @@ const SharedPanelContent = ({
17081792 position : 'relative' ,
17091793 background : '#cfcfcf'
17101794 } } >
1711- < style > { `@keyframes panelImgShimmer { 0% { transform: translateY(100%); } 100% { transform: translateY(-100%); } }` } </ style >
1712- < div style = { {
1713- position : 'absolute' ,
1714- left : 0 ,
1715- right : 0 ,
1716- height : '60%' ,
1717- background : 'linear-gradient(to top, rgba(255,255,255,0) 0%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 100%)' ,
1718- animation : 'panelImgShimmer 1.2s ease-in-out infinite'
1719- } } />
1795+ < PanelImageShimmer />
17201796 </ div >
17211797 ) }
17221798 { ! hasImage && ! imageLoading && (
0 commit comments