Skip to content

Commit fea6beb

Browse files
fix(core): size gallery images to their grid cell, not the viewport (emdash-cms#3103)
Gallery.astro gave provider images sizes="(min-width: Wpx) Wpx, 100vw" and left locally stored images on Astro's constrained default, the same estimate. Each image renders in one of columns cells (two at 640px and below), so browsers picked srcset candidates several times wider than the cell. sizes now comes from gallerySizes(columns), which mirrors the gallery CSS including its 1rem gap, and applies to both the provider img and the AstroImage path. A new sizes prop overrides it for galleries rendered in a narrower container. Closes emdash-cms#2930
1 parent a89c37e commit fea6beb

5 files changed

Lines changed: 192 additions & 4 deletions

File tree

.changeset/gallery-cell-sizes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes the Portable Text `Gallery` component telling browsers each image fills the viewport. Its `sizes` attribute now describes one grid cell, using the gallery's column count and its two-column layout at 640px and below, so browsers stop downloading oversized images for multi-column galleries. Pass the new `sizes` prop to `Gallery` when it renders in a container narrower than the viewport.

packages/core/src/components/Gallery.astro

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { Image as AstroImage } from "astro:assets";
1111
import type { ImageEmbed } from "../media/types.js";
1212
import { getMediaProvider } from "../media/provider-loader.js";
1313
import { buildRenderMediaUrl } from "../media/url.js";
14-
import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js";
14+
import {
15+
toAbsoluteMediaUrl,
16+
gallerySizes,
17+
GALLERY_DEFAULT_COLUMNS,
18+
RESPONSIVE_BREAKPOINTS,
19+
} from "../media/responsive.js";
1520
import { getPublicOrigin } from "../api/public-url.js";
1621
import { focalPointToObjectPosition } from "../media/focal-point.js";
1722
@@ -43,6 +48,12 @@ export interface Props {
4348
};
4449
/** Render the LQIP placeholder as an inline background. Disable for strict CSP. */
4550
placeholder?: boolean;
51+
/**
52+
* `sizes` attribute for every image in the gallery. Defaults to an estimate
53+
* from the column count against the viewport; set it when the gallery renders
54+
* in a narrower container, e.g. `"(max-width: 640px) 45vw, 220px"`.
55+
*/
56+
sizes?: string;
4657
}
4758
4859
/**
@@ -61,9 +72,11 @@ function generateSrcset(
6172
.join(", ");
6273
}
6374
64-
const { node, placeholder = true } = Astro.props;
75+
const { node, placeholder = true, sizes: sizesOverride } = Astro.props;
6576
const images = node?.images ?? [];
66-
const columns = node?.columns ?? 3;
77+
const columns = node?.columns ?? GALLERY_DEFAULT_COLUMNS;
78+
// Each image fills one grid cell, not the viewport.
79+
const cellSizes = sizesOverride ?? gallerySizes(columns);
6780
6881
if (!images.length) {
6982
return null;
@@ -102,7 +115,7 @@ const resolvedImages = await Promise.all(
102115
if (embed.getSrc) {
103116
const maxWidth = width || 1200;
104117
srcset = generateSrcset(embed.getSrc, maxWidth, aspectRatio);
105-
sizes = width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw";
118+
sizes = cellSizes;
106119
}
107120
}
108121
} catch (error) {
@@ -166,6 +179,7 @@ const resolvedImages = await Promise.all(
166179
width={image.width!}
167180
height={image.height!}
168181
layout="constrained"
182+
sizes={cellSizes}
169183
style={image.objectPosition ? `object-position: ${image.objectPosition};` : undefined}
170184
/>
171185
) : (

packages/core/src/media/responsive.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,29 @@ export function responsiveSizes(width: number | undefined): string {
3232
return width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw";
3333
}
3434

35+
/** Column count `Gallery.astro` uses when a gallery block does not set one. */
36+
export const GALLERY_DEFAULT_COLUMNS = 3;
37+
38+
/**
39+
* Build the `sizes` attribute for one cell of a Portable Text gallery.
40+
*
41+
* Mirrors the gallery's CSS: `columns` equal cells with a `1rem` gap, and two
42+
* columns at `640px` and below. Each cell is estimated against the viewport, so
43+
* a gallery inside a narrower content column still gets a larger slot than it
44+
* renders at; pass `sizes` to `Gallery` when the container width is known.
45+
*/
46+
export function gallerySizes(columns: number | undefined): string {
47+
const count =
48+
typeof columns === "number" && Number.isInteger(columns) && columns > 0
49+
? columns
50+
: GALLERY_DEFAULT_COLUMNS;
51+
return `(max-width: 640px) ${galleryCell(2)}, ${galleryCell(count)}`;
52+
}
53+
54+
function galleryCell(columns: number): string {
55+
return columns === 1 ? "100vw" : `calc((100vw - ${columns - 1}rem) / ${columns})`;
56+
}
57+
3558
/**
3659
* Make a same-origin media URL absolute so Astro's image service can optimize it.
3760
*
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* The gallery's `sizes` must describe a grid cell, not the viewport: each
3+
* image renders in one of `columns` cells (two at 640px and below), and a
4+
* `100vw` estimate lets the browser pick a far larger srcset candidate than
5+
* the cell needs (#2930).
6+
*/
7+
import { experimental_AstroContainer as AstroContainer } from "astro/container";
8+
import { describe, expect, test } from "vitest";
9+
10+
import Gallery from "../../src/components/Gallery.astro";
11+
12+
const testMediaProviders = [
13+
{
14+
id: "mock-gallery-images",
15+
name: "Mock Gallery Images",
16+
capabilities: { list: false, upload: false, delete: false, metadata: false },
17+
createProvider: () => ({
18+
id: "mock-gallery-images",
19+
name: "Mock Gallery Images",
20+
capabilities: { list: false, upload: false, delete: false, metadata: false },
21+
getEmbed: (_value: unknown, options: { width?: number; height?: number } = {}) => ({
22+
type: "image",
23+
src: `https://img.example.com/original?w=${options.width ?? "auto"}`,
24+
getSrc: ({ width, height }: { width?: number; height?: number } = {}) =>
25+
`https://img.example.com/render?w=${width ?? "auto"}&h=${height ?? "auto"}`,
26+
}),
27+
}),
28+
},
29+
];
30+
31+
const providerGlobal = globalThis as typeof globalThis & {
32+
__emdashTestMediaProviders?: typeof testMediaProviders;
33+
};
34+
providerGlobal.__emdashTestMediaProviders = [
35+
...(providerGlobal.__emdashTestMediaProviders ?? []),
36+
...testMediaProviders,
37+
];
38+
39+
const locals = {
40+
emdash: { getPublicMediaUrl: (k: string) => `/_emdash/api/media/file/${k}` },
41+
};
42+
43+
const imgTags = (html: string) => html.match(/<img\b[^>]*>/g) ?? [];
44+
const attr = (tag: string, name: string) =>
45+
tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1]?.replaceAll("&amp;", "&");
46+
47+
function providerImage(key: string) {
48+
return {
49+
_type: "image" as const,
50+
_key: key,
51+
asset: { _ref: `provider-${key}`, provider: "mock-gallery-images" },
52+
alt: key,
53+
width: 1600,
54+
height: 1200,
55+
};
56+
}
57+
58+
async function renderGallery(props: Record<string, unknown>) {
59+
const container = await AstroContainer.create();
60+
return container.renderToString(Gallery, { props, locals });
61+
}
62+
63+
describe("Gallery sizes", () => {
64+
test("provider images are sized to one of three default columns", async () => {
65+
const html = await renderGallery({
66+
node: { _type: "gallery", _key: "g", images: [providerImage("a"), providerImage("b")] },
67+
});
68+
const tags = imgTags(html);
69+
70+
expect(tags).toHaveLength(2);
71+
for (const tag of tags) {
72+
expect(attr(tag, "srcset")).toContain("https://img.example.com/render?w=640");
73+
expect(attr(tag, "sizes")).toBe(
74+
"(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
75+
);
76+
}
77+
});
78+
79+
test("the slot follows the block's column count", async () => {
80+
const html = await renderGallery({
81+
node: { _type: "gallery", _key: "g", columns: 4, images: [providerImage("a")] },
82+
});
83+
84+
expect(attr(imgTags(html)[0]!, "sizes")).toBe(
85+
"(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 3rem) / 4)",
86+
);
87+
});
88+
89+
test("locally stored images with dimensions get the same cell estimate", async () => {
90+
const html = await renderGallery({
91+
node: {
92+
_type: "gallery",
93+
_key: "g",
94+
images: [
95+
{
96+
_type: "image",
97+
_key: "local",
98+
asset: { _ref: "media-1", url: "/_emdash/api/media/file/local.jpg" },
99+
alt: "local",
100+
width: 1600,
101+
height: 1200,
102+
},
103+
],
104+
},
105+
});
106+
const tag = imgTags(html)[0]!;
107+
108+
expect(attr(tag, "data-astro-image")).toBe("constrained");
109+
expect(attr(tag, "sizes")).toBe(
110+
"(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
111+
);
112+
});
113+
114+
test("a consumer-provided sizes wins", async () => {
115+
const html = await renderGallery({
116+
node: { _type: "gallery", _key: "g", images: [providerImage("a")] },
117+
sizes: "(max-width: 640px) 45vw, 220px",
118+
});
119+
120+
expect(attr(imgTags(html)[0]!, "sizes")).toBe("(max-width: 640px) 45vw, 220px");
121+
});
122+
});

packages/core/tests/unit/media/responsive.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest";
33
import {
44
RESPONSIVE_BREAKPOINTS,
55
buildResponsiveImage,
6+
gallerySizes,
67
responsiveSizes,
78
responsiveWidths,
89
toAbsoluteMediaUrl,
@@ -42,6 +43,29 @@ describe("responsiveSizes", () => {
4243
});
4344
});
4445

46+
describe("gallerySizes", () => {
47+
it("estimates one cell of the grid, with two columns on narrow viewports", () => {
48+
expect(gallerySizes(3)).toBe(
49+
"(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
50+
);
51+
expect(gallerySizes(5)).toBe(
52+
"(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 4rem) / 5)",
53+
);
54+
});
55+
56+
it("uses the full viewport for a single column above the breakpoint", () => {
57+
expect(gallerySizes(1)).toBe("(max-width: 640px) calc((100vw - 1rem) / 2), 100vw");
58+
});
59+
60+
it("falls back to three columns for a missing or unusable count", () => {
61+
const three = gallerySizes(3);
62+
expect(gallerySizes(undefined)).toBe(three);
63+
expect(gallerySizes(0)).toBe(three);
64+
expect(gallerySizes(2.5)).toBe(three);
65+
expect(gallerySizes(Number.NaN)).toBe(three);
66+
});
67+
});
68+
4569
describe("buildResponsiveImage", () => {
4670
const ABS = "https://cdn.example.com/a.jpg";
4771

0 commit comments

Comments
 (0)