Skip to content

Commit 0f4b124

Browse files
committed
feat: Implement PanelImage component with loading shimmer and aspect ratio handling for improved image rendering
1 parent 0ff4193 commit 0f4b124

1 file changed

Lines changed: 109 additions & 33 deletions

File tree

src/components/panel/SharedPanelContent.jsx

Lines changed: 109 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -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
30116
const 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

Comments
 (0)