diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index a238382..840e9af 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -27,8 +27,9 @@ export function AlbumBubble({ post }: { post: Post }) { return (
- {/* aspect-ratio lives on the wrapper so the grid gets a *definite* height - (inset-0) and its fr rows stretch instead of collapsing to content. */} + {/* aspect-ratio sets a definite box height; tiles are absolutely + positioned by percentage so the mosaic fills it exactly (no CSS-grid + `fr` quirks, no leftover black band). */}
-
- {visible.map((att, i) => { - const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; - const tile = layout?.tiles[i]; - return ( -
{ + const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; + const tile = layout?.tiles[i]; + if (!tile) return null; + return ( +
+ - {!isLastSlot ? ( - + + {isLastSlot ? ( +
+ + +{extra} + +
) : null} -
- ); - })} -
+ + {!isLastSlot ? ( + + ) : null} +
+ ); + })}
{text ? (
diff --git a/src/components/messageStream/utils/albumLayout.test.ts b/src/components/messageStream/utils/albumLayout.test.ts index 87ea9d6..4b102cc 100644 --- a/src/components/messageStream/utils/albumLayout.test.ts +++ b/src/components/messageStream/utils/albumLayout.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeAlbumLayout } from "./albumLayout"; +import { computeAlbumLayout, type AlbumTile } from "./albumLayout"; + +/** Right/bottom edge of a tile, as fractions of the box. */ +const right = (t: AlbumTile) => t.left + t.width; +const bottom = (t: AlbumTile) => t.top + t.height; describe("computeAlbumLayout", () => { it("returns null for fewer than 2 images", () => { @@ -7,48 +11,49 @@ describe("computeAlbumLayout", () => { expect(computeAlbumLayout([1.5])).toBeNull(); }); - it("places two portraits side by side (1 row, 2 cols)", () => { + it("places two portraits side by side, splitting the full width", () => { const layout = computeAlbumLayout([0.7, 0.8])!; - expect(layout.gridTemplateRows).toBe("1fr"); - expect(layout.tiles).toHaveLength(2); - // Both tiles occupy the single row, adjacent columns. - expect(layout.tiles[0]).toMatchObject({ colStart: 1, colEnd: 2 }); - expect(layout.tiles[1]).toMatchObject({ colStart: 2, colEnd: 3 }); - // Album width = sum of ratios when height = 1. expect(layout.aspectRatio).toBeCloseTo(0.7 + 0.8, 4); + expect(layout.tiles).toHaveLength(2); + // Full height each, adjacent columns covering the whole width. + expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, height: 1 }); + expect(layout.tiles[1].top).toBe(0); + expect(layout.tiles[1].height).toBe(1); + expect(right(layout.tiles[0])).toBeCloseTo(layout.tiles[1].left, 6); + expect(right(layout.tiles[1])).toBeCloseTo(1, 6); }); - it("stacks two landscapes vertically (2 rows, 1 col)", () => { + it("stacks two landscapes vertically, splitting the full height", () => { const layout = computeAlbumLayout([1.6, 1.4])!; - expect(layout.gridTemplateColumns).toBe("1fr"); - expect(layout.tiles[0]).toMatchObject({ rowStart: 1, rowEnd: 2 }); - expect(layout.tiles[1]).toMatchObject({ rowStart: 2, rowEnd: 3 }); expect(layout.aspectRatio).toBeCloseTo(1 / (1 / 1.6 + 1 / 1.4), 4); + expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, width: 1 }); + expect(layout.tiles[1].left).toBe(0); + expect(layout.tiles[1].width).toBe(1); + expect(bottom(layout.tiles[0])).toBeCloseTo(layout.tiles[1].top, 6); + expect(bottom(layout.tiles[1])).toBeCloseTo(1, 6); }); - it("puts a portrait primary on the left with the rest stacked right", () => { + it("portrait primary on the left, rest stacked on the right, fully covering", () => { const layout = computeAlbumLayout([0.6, 1.2, 1.3])!; - // Primary spans both right-hand rows in column 1. - expect(layout.tiles[0]).toEqual({ - colStart: 1, - colEnd: 2, - rowStart: 1, - rowEnd: 3, - }); - expect(layout.tiles[1]).toMatchObject({ colStart: 2, rowStart: 1 }); - expect(layout.tiles[2]).toMatchObject({ colStart: 2, rowStart: 2 }); + // Primary occupies the full-height left column. + expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, height: 1 }); + // Secondary tiles share the right column and fill it top-to-bottom. + expect(layout.tiles[1].left).toBeCloseTo(layout.tiles[0].width, 6); + expect(layout.tiles[1].top).toBe(0); + expect(bottom(layout.tiles[1])).toBeCloseTo(layout.tiles[2].top, 6); + expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6); + expect(right(layout.tiles[2])).toBeCloseTo(1, 6); }); - it("puts a landscape primary on top with the rest in a row below", () => { + it("landscape primary on top, rest in a bottom row, fully covering", () => { const layout = computeAlbumLayout([1.8, 0.9, 1.1])!; - // Primary spans both bottom columns in row 1. - expect(layout.tiles[0]).toEqual({ - colStart: 1, - colEnd: 3, - rowStart: 1, - rowEnd: 2, - }); - expect(layout.tiles[1]).toMatchObject({ rowStart: 2, colStart: 1 }); - expect(layout.tiles[2]).toMatchObject({ rowStart: 2, colStart: 2 }); + // Primary occupies the full-width top row. + expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, width: 1 }); + // Secondary tiles share the bottom row and fill it left-to-right. + expect(layout.tiles[1].top).toBeCloseTo(layout.tiles[0].height, 6); + expect(layout.tiles[1].left).toBe(0); + expect(right(layout.tiles[1])).toBeCloseTo(layout.tiles[2].left, 6); + expect(right(layout.tiles[2])).toBeCloseTo(1, 6); + expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6); }); }); diff --git a/src/components/messageStream/utils/albumLayout.ts b/src/components/messageStream/utils/albumLayout.ts index 0ab4e5b..2ea0488 100644 --- a/src/components/messageStream/utils/albumLayout.ts +++ b/src/components/messageStream/utils/albumLayout.ts @@ -2,12 +2,14 @@ * Telegram-style adaptive album layout. * * Given the aspect ratios (width / height) of the images in an album, computes - * a CSS-grid mosaic where each cell is sized close to its image's real ratio, - * so the collage looks tidy with minimal cropping. + * an absolutely-positioned mosaic: each tile gets normalized {left, top, width, + * height} as fractions (0..1) of the album box, plus the box's overall aspect + * ratio. Absolute positioning (rather than CSS grid `fr`) keeps the layout + * robust — it only depends on the container's resolved size. * * Rules (mirroring the product's request): - * - 2 images, both portrait -> side by side (1 row, 2 cols) - * - 2 images, both landscape -> stacked (2 rows, 1 col) + * - 2 images, both portrait -> side by side (left/right) + * - 2 images, both landscape -> stacked (top/bottom) * - 2 images, mixed -> side by side, proportional * - 3+ images -> "primary + line": the first image is the big * one; a portrait primary sits on the LEFT with the rest stacked on the @@ -15,15 +17,14 @@ */ export type AlbumTile = { - colStart: number; - colEnd: number; - rowStart: number; - rowEnd: number; + /** Fractions (0..1) of the album box. */ + left: number; + top: number; + width: number; + height: number; }; export type AlbumLayout = { - gridTemplateColumns: string; - gridTemplateRows: string; /** width / height of the whole album box. */ aspectRatio: number; tiles: AlbumTile[]; @@ -38,10 +39,6 @@ function clampRatio(ratio: number | undefined): number { return Math.min(2, Math.max(0.55, ratio)); } -function fr(values: number[]): string { - return values.map((v) => `${Number(v.toFixed(4))}fr`).join(" "); -} - function layoutTwo(ratios: number[]): AlbumLayout { const [r0, r1] = ratios; const bothLandscape = r0 >= 1 && r1 >= 1; @@ -50,25 +47,23 @@ function layoutTwo(ratios: number[]): AlbumLayout { // Stacked: equal width, heights follow each ratio. const h0 = 1 / r0; const h1 = 1 / r1; + const total = h0 + h1; return { - gridTemplateColumns: "1fr", - gridTemplateRows: fr([h0, h1]), - aspectRatio: 1 / (h0 + h1), + aspectRatio: 1 / total, tiles: [ - { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, - { colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 }, + { left: 0, top: 0, width: 1, height: h0 / total }, + { left: 0, top: h0 / total, width: 1, height: h1 / total }, ], }; } // Portrait or mixed: side by side, equal height, widths follow each ratio. + const w0 = r0 / (r0 + r1); return { - gridTemplateColumns: fr([r0, r1]), - gridTemplateRows: "1fr", aspectRatio: r0 + r1, tiles: [ - { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 }, - { colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 }, + { left: 0, top: 0, width: w0, height: 1 }, + { left: w0, top: 0, width: 1 - w0, height: 1 }, ], }; } @@ -79,42 +74,39 @@ function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout { const primaryOnLeft = r0 < 1; // portrait primary -> left; landscape -> top if (primaryOnLeft) { - // Left column holds the primary (spans all rows). Right column is split - // into one row per secondary image; share width `wr`, heights follow ratio. + // Total height = 1. Left column holds the primary (full height); right + // column is split into one row per secondary image (share width `wr`). const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0); - const wr = 1 / invSum; // right column width when total height = 1 - const wl = r0; // primary width when height = 1 - const tiles: AlbumTile[] = [ - { colStart: 1, colEnd: 2, rowStart: 1, rowEnd: secondary.length + 1 }, - ]; - secondary.forEach((_, i) => { - tiles.push({ colStart: 2, colEnd: 3, rowStart: i + 1, rowEnd: i + 2 }); + const wrPx = 1 / invSum; // right-column width in box-height units + const total = r0 + wrPx; // box width when box height = 1 (== aspectRatio) + const wl = r0 / total; + const wr = wrPx / total; + const tiles: AlbumTile[] = [{ left: 0, top: 0, width: wl, height: 1 }]; + let y = 0; + secondary.forEach((r) => { + const h = wrPx / r; // tile height as a fraction of box height (= 1) + tiles.push({ left: wl, top: y, width: wr, height: h }); + y += h; }); - return { - gridTemplateColumns: fr([wl, wr]), - gridTemplateRows: fr(secondary.map((r) => 1 / r)), - aspectRatio: wl + wr, - tiles, - }; + return { aspectRatio: total, tiles }; } - // Primary on top (spans all columns). Bottom row holds the secondary images - // side by side; share height `hb`, widths follow ratio. + // Total width = 1. Primary on top (full width); secondary share a bottom row + // (common height `hb`, widths follow ratio). const sumR = secondary.reduce((acc, r) => acc + r, 0); - const ht = 1 / r0; // primary height when total width = 1 - const hb = 1 / sumR; // bottom row height when total width = 1 + const ht = 1 / r0; + const hb = 1 / sumR; + const total = ht + hb; // box height when width = 1 const tiles: AlbumTile[] = [ - { colStart: 1, colEnd: secondary.length + 1, rowStart: 1, rowEnd: 2 }, + { left: 0, top: 0, width: 1, height: ht / total }, ]; - secondary.forEach((_, i) => { - tiles.push({ colStart: i + 1, colEnd: i + 2, rowStart: 2, rowEnd: 3 }); + let x = 0; + secondary.forEach((r) => { + const w = r / sumR; + tiles.push({ left: x, top: ht / total, width: w, height: hb / total }); + x += w; }); - return { - gridTemplateColumns: fr(secondary.map((r) => r)), - gridTemplateRows: fr([ht, hb]), - aspectRatio: 1 / (ht + hb), - tiles, - }; + return { aspectRatio: 1 / total, tiles }; } /**