diff --git a/db/model/Gdoc/GdocBase.ts b/db/model/Gdoc/GdocBase.ts index a2a4f594590..421ca0cb12c 100644 --- a/db/model/Gdoc/GdocBase.ts +++ b/db/model/Gdoc/GdocBase.ts @@ -96,6 +96,9 @@ export class GdocBase extends BaseEntity implements OwidGdocBaseInterface { if ("filename" in item && item.filename) { filenames.add(item.filename) } + if (item.type === "image" && item.smallFilename) { + filenames.add(item.smallFilename) + } if (item.type === "prominent-link" && item.thumbnail) { filenames.add(item.thumbnail) } diff --git a/db/model/Gdoc/rawToEnriched.ts b/db/model/Gdoc/rawToEnriched.ts index efdd29084aa..bd497e098bd 100644 --- a/db/model/Gdoc/rawToEnriched.ts +++ b/db/model/Gdoc/rawToEnriched.ts @@ -601,6 +601,7 @@ const parseImage = (image: RawBlockImage): EnrichedBlockImage => { return { type: "image", filename, + smallFilename: image.value.smallFilename, alt: image.value.alt, caption, size, diff --git a/packages/@ourworldindata/components/src/styles/variables.scss b/packages/@ourworldindata/components/src/styles/variables.scss index f50a8047957..0afb4c6a0a1 100644 --- a/packages/@ourworldindata/components/src/styles/variables.scss +++ b/packages/@ourworldindata/components/src/styles/variables.scss @@ -9,7 +9,7 @@ $vertical-spacing: 16px; :root { --grid-gap: 24px; - @media (max-width: 798px) { + @media (max-width: 768px) { --grid-gap: 16px; } } @@ -52,7 +52,7 @@ $search-cta-height: 40px; * Breakpoints */ -// Calculations in Image.tsx rely on these variables. Please change them there if you change them here. +// Calculations in Image.tsx and image.ts rely on these variables. Please change them there if you change them here. $sm: 768px; $md: 960px; $lg: 1280px; diff --git a/packages/@ourworldindata/utils/src/image.ts b/packages/@ourworldindata/utils/src/image.ts index dabbc099895..3cb628f8879 100644 --- a/packages/@ourworldindata/utils/src/image.ts +++ b/packages/@ourworldindata/utils/src/image.ts @@ -62,3 +62,34 @@ export function getFilenameWithoutExtension( export function getFilenameAsPng(filename: ImageMetadata["filename"]): string { return `${getFilenameWithoutExtension(filename)}.png` } + +export type SourceProps = { + media: string | undefined + srcSet: string +} + +/** + * When we have a small and large image, we want to generate two elements. + * The first will only be active at small screen sizes, due to its `media` property. + * The second will be active at all screen sizes. + * These props work in conjuction with a `sizes` attribute on the element. + */ +export function generateSourceProps( + smallImage: ImageMetadata | undefined, + regularImage: ImageMetadata +): SourceProps[] { + const props: SourceProps[] = [] + if (smallImage) { + const smallSizes = getSizes(smallImage.originalWidth) + props.push({ + media: "(max-width: 768px)", + srcSet: generateSrcSet(smallSizes, smallImage.filename), + }) + } + const regularSizes = getSizes(regularImage.originalWidth) + props.push({ + media: undefined, + srcSet: generateSrcSet(regularSizes, regularImage.filename), + }) + return props +} diff --git a/packages/@ourworldindata/utils/src/index.ts b/packages/@ourworldindata/utils/src/index.ts index c1cc3db56ed..d212ebbdd8f 100644 --- a/packages/@ourworldindata/utils/src/index.ts +++ b/packages/@ourworldindata/utils/src/index.ts @@ -624,6 +624,8 @@ export { getFilenameAsPng, type GDriveImageMetadata, type ImageMetadata, + type SourceProps, + generateSourceProps, } from "./image.js" export { Tippy, TippyIfInteractive } from "./Tippy.js" diff --git a/packages/@ourworldindata/utils/src/owidTypes.ts b/packages/@ourworldindata/utils/src/owidTypes.ts index 2f0f305b642..481b0272e7a 100644 --- a/packages/@ourworldindata/utils/src/owidTypes.ts +++ b/packages/@ourworldindata/utils/src/owidTypes.ts @@ -706,6 +706,7 @@ export type RawBlockImage = { type: "image" value: { filename?: string + smallFilename?: string alt?: string caption?: string size?: BlockImageSize @@ -715,6 +716,7 @@ export type RawBlockImage = { export type EnrichedBlockImage = { type: "image" filename: string + smallFilename?: string alt?: string // optional as we can use the default alt from the file caption?: Span[] originalWidth?: number diff --git a/site/Lightbox.tsx b/site/Lightbox.tsx index 3530a94a2b1..8ad77ba353d 100644 --- a/site/Lightbox.tsx +++ b/site/Lightbox.tsx @@ -15,6 +15,27 @@ import { useTriggerOnEscape } from "./hooks.js" export const LIGHTBOX_IMAGE_CLASS = "lightbox-image" +/** + * If the image is inside a element, get the URL of the largest + * source image that matches the current viewport size. + */ +function getActiveSourceImgUrl(img: HTMLImageElement): string | undefined { + if (img.closest("picture")) { + const sources = img.closest("picture")!.querySelectorAll("source") + const activeSource = Array.from(sources).find((s) => + s.media ? window.matchMedia(s.media).matches : true + ) + const sourceSrcset = activeSource?.getAttribute("srcset") + // split sourceSrcset into [src, width] pairs + const srcsetPairs = sourceSrcset + ?.split(",") + .map((pair) => pair.trim().split(" ")) + const largestImgSrc = srcsetPairs?.at(-1)?.[0] + return largestImgSrc + } + return undefined +} + const Lightbox = ({ children, containerNode, @@ -145,9 +166,17 @@ export const runLightbox = () => { img.classList.add("lightbox-enabled") img.addEventListener("click", () => { - // getAttribute doesn't automatically URI encode values, img.src does + // An attribute placed by our WP image formatter: the URL of the original image without any WxH suffix const highResSrc = img.getAttribute("data-high-res-src") - const imgSrc = highResSrc ? encodeURI(highResSrc) : img.src + // If the image is a Gdoc Image with a smallFilename, get the source that is currently active + const activeSourceImgUrl = getActiveSourceImgUrl(img) + const imgSrc = highResSrc + ? // getAttribute doesn't automatically URI encode values, img.src does + encodeURI(highResSrc) + : activeSourceImgUrl + ? activeSourceImgUrl + : img.src + const imgAlt = img.alt if (imgSrc) { ReactDOM.render( diff --git a/site/gdocs/ArticleBlock.tsx b/site/gdocs/ArticleBlock.tsx index e5463c685e9..72b239b7360 100644 --- a/site/gdocs/ArticleBlock.tsx +++ b/site/gdocs/ArticleBlock.tsx @@ -228,6 +228,7 @@ export default function ArticleBlock({ > {block.alt} diff --git a/site/gdocs/Image.tsx b/site/gdocs/Image.tsx index 814821a5da2..5b1ff1da863 100644 --- a/site/gdocs/Image.tsx +++ b/site/gdocs/Image.tsx @@ -1,12 +1,11 @@ import React, { useContext } from "react" import { - getSizes, - generateSrcSet, getFilenameWithoutExtension, IMAGES_DIRECTORY, + generateSourceProps, + ImageMetadata, } from "@ourworldindata/utils" import { LIGHTBOX_IMAGE_CLASS } from "../Lightbox.js" -import cx from "classnames" import { IMAGE_HOSTING_BUCKET_SUBFOLDER_PATH, IMAGE_HOSTING_CDN_URL, @@ -27,11 +26,12 @@ const generateResponsiveSizes = (numberOfColumns: number): string => const gridSpan5 = generateResponsiveSizes(5) const gridSpan6 = generateResponsiveSizes(6) const gridSpan7 = generateResponsiveSizes(7) +const gridSpan8 = generateResponsiveSizes(8) type ImageParentContainer = Container | "thumbnail" | "full-width" const containerSizes: Record = { - ["default"]: gridSpan6, + ["default"]: gridSpan8, ["sticky-right-left-column"]: gridSpan5, ["sticky-right-right-column"]: gridSpan7, ["sticky-left-left-column"]: gridSpan7, @@ -46,6 +46,7 @@ const containerSizes: Record = { export default function Image(props: { filename: string + smallFilename?: string alt?: string className?: string containerType?: ImageParentContainer @@ -53,26 +54,35 @@ export default function Image(props: { }) { const { filename, + smallFilename, className = "", containerType = "default", shouldLightbox = true, } = props const { isPreviewing } = useContext(DocumentContext) const image = useImage(filename) + const smallImage = useImage(smallFilename) + const renderImageError = (name: string) => ( + + ) + if (!image) { if (isPreviewing) { - return ( - - ) + return renderImageError(filename) } + // Don't render anything if we're not previewing (i.e. a bake) and the image is not found return null } + // Here we can fall back to the regular image filename, so don't return null if not found + if (isPreviewing && smallFilename && !smallImage) { + return renderImageError(smallFilename) + } const alt = props.alt ?? image.defaultAlt const maybeLightboxClassName = @@ -81,13 +91,36 @@ export default function Image(props: { : LIGHTBOX_IMAGE_CLASS if (isPreviewing) { - const previewSrc = `${IMAGE_HOSTING_CDN_URL}/${IMAGE_HOSTING_BUCKET_SUBFOLDER_PATH}/${filename}` + const makePreviewUrl = (f: string) => + encodeURI( + `${IMAGE_HOSTING_CDN_URL}/${IMAGE_HOSTING_BUCKET_SUBFOLDER_PATH}/${f}` + ) + + const PreviewSource = (props: { i?: ImageMetadata; sm?: boolean }) => { + const { i, sm } = props + if (!i) return null + + return ( + + ) + } return ( - {alt} + + + + {alt} + ) } @@ -114,17 +147,21 @@ export default function Image(props: { ) } - const sizes = getSizes(image.originalWidth) - const srcSet = generateSrcSet(sizes, filename) const imageSrc = `${IMAGES_DIRECTORY}${filename}` + const sourceProps = generateSourceProps(smallImage, image) return ( - + {sourceProps.map((props, i) => ( + + ))}