terry-media-adaptive-trial #10
@@ -27,47 +27,37 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* aspect-ratio lives on the wrapper so the grid gets a *definite* height
|
{/* aspect-ratio sets a definite box height; tiles are absolutely
|
||||||
(inset-0) and its fr rows stretch instead of collapsing to content. */}
|
positioned by percentage so the mosaic fills it exactly (no CSS-grid
|
||||||
|
`fr` quirks, no leftover black band). */}
|
||||||
<div
|
<div
|
||||||
className="relative w-full overflow-hidden bg-black"
|
className="relative w-full overflow-hidden bg-black"
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: layout?.aspectRatio,
|
aspectRatio: layout?.aspectRatio,
|
||||||
maxHeight: ALBUM_MAX_HEIGHT,
|
maxHeight: ALBUM_MAX_HEIGHT,
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 grid"
|
|
||||||
style={{
|
|
||||||
gap: ALBUM_GAP,
|
|
||||||
gridTemplateColumns: layout?.gridTemplateColumns,
|
|
||||||
gridTemplateRows: layout?.gridTemplateRows,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{visible.map((att, i) => {
|
{visible.map((att, i) => {
|
||||||
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
|
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
|
||||||
const tile = layout?.tiles[i];
|
const tile = layout?.tiles[i];
|
||||||
|
if (!tile) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={att.id}
|
key={att.id}
|
||||||
className="relative overflow-hidden"
|
className="absolute overflow-hidden"
|
||||||
style={
|
style={{
|
||||||
tile
|
left: `${tile.left * 100}%`,
|
||||||
? {
|
top: `${tile.top * 100}%`,
|
||||||
gridColumn: `${tile.colStart} / ${tile.colEnd}`,
|
width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`,
|
||||||
gridRow: `${tile.rowStart} / ${tile.rowEnd}`,
|
height: `calc(${tile.height * 100}% - ${ALBUM_GAP}px)`,
|
||||||
}
|
}}
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openLightbox(images, i, text, post.id)}
|
onClick={() => openLightbox(images, i, text, post.id)}
|
||||||
className="group block h-full w-full"
|
className="group block h-full w-full"
|
||||||
aria-label={
|
aria-label={
|
||||||
isLastSlot
|
isLastSlot ? `View all ${images.length} images` : "View image"
|
||||||
? `View all ${images.length} images`
|
|
||||||
: "View image"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<BubbleImage
|
<BubbleImage
|
||||||
@@ -91,7 +81,6 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
|
||||||
{autolink(text)}
|
{autolink(text)}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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", () => {
|
describe("computeAlbumLayout", () => {
|
||||||
it("returns null for fewer than 2 images", () => {
|
it("returns null for fewer than 2 images", () => {
|
||||||
@@ -7,48 +11,49 @@ describe("computeAlbumLayout", () => {
|
|||||||
expect(computeAlbumLayout([1.5])).toBeNull();
|
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])!;
|
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.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])!;
|
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.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])!;
|
const layout = computeAlbumLayout([0.6, 1.2, 1.3])!;
|
||||||
// Primary spans both right-hand rows in column 1.
|
// Primary occupies the full-height left column.
|
||||||
expect(layout.tiles[0]).toEqual({
|
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, height: 1 });
|
||||||
colStart: 1,
|
// Secondary tiles share the right column and fill it top-to-bottom.
|
||||||
colEnd: 2,
|
expect(layout.tiles[1].left).toBeCloseTo(layout.tiles[0].width, 6);
|
||||||
rowStart: 1,
|
expect(layout.tiles[1].top).toBe(0);
|
||||||
rowEnd: 3,
|
expect(bottom(layout.tiles[1])).toBeCloseTo(layout.tiles[2].top, 6);
|
||||||
});
|
expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6);
|
||||||
expect(layout.tiles[1]).toMatchObject({ colStart: 2, rowStart: 1 });
|
expect(right(layout.tiles[2])).toBeCloseTo(1, 6);
|
||||||
expect(layout.tiles[2]).toMatchObject({ colStart: 2, rowStart: 2 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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])!;
|
const layout = computeAlbumLayout([1.8, 0.9, 1.1])!;
|
||||||
// Primary spans both bottom columns in row 1.
|
// Primary occupies the full-width top row.
|
||||||
expect(layout.tiles[0]).toEqual({
|
expect(layout.tiles[0]).toMatchObject({ left: 0, top: 0, width: 1 });
|
||||||
colStart: 1,
|
// Secondary tiles share the bottom row and fill it left-to-right.
|
||||||
colEnd: 3,
|
expect(layout.tiles[1].top).toBeCloseTo(layout.tiles[0].height, 6);
|
||||||
rowStart: 1,
|
expect(layout.tiles[1].left).toBe(0);
|
||||||
rowEnd: 2,
|
expect(right(layout.tiles[1])).toBeCloseTo(layout.tiles[2].left, 6);
|
||||||
});
|
expect(right(layout.tiles[2])).toBeCloseTo(1, 6);
|
||||||
expect(layout.tiles[1]).toMatchObject({ rowStart: 2, colStart: 1 });
|
expect(bottom(layout.tiles[2])).toBeCloseTo(1, 6);
|
||||||
expect(layout.tiles[2]).toMatchObject({ rowStart: 2, colStart: 2 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
* Telegram-style adaptive album layout.
|
* Telegram-style adaptive album layout.
|
||||||
*
|
*
|
||||||
* Given the aspect ratios (width / height) of the images in an album, computes
|
* 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,
|
* an absolutely-positioned mosaic: each tile gets normalized {left, top, width,
|
||||||
* so the collage looks tidy with minimal cropping.
|
* 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):
|
* Rules (mirroring the product's request):
|
||||||
* - 2 images, both portrait -> side by side (1 row, 2 cols)
|
* - 2 images, both portrait -> side by side (left/right)
|
||||||
* - 2 images, both landscape -> stacked (2 rows, 1 col)
|
* - 2 images, both landscape -> stacked (top/bottom)
|
||||||
* - 2 images, mixed -> side by side, proportional
|
* - 2 images, mixed -> side by side, proportional
|
||||||
* - 3+ images -> "primary + line": the first image is the big
|
* - 3+ images -> "primary + line": the first image is the big
|
||||||
* one; a portrait primary sits on the LEFT with the rest stacked on the
|
* one; a portrait primary sits on the LEFT with the rest stacked on the
|
||||||
@@ -15,15 +17,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type AlbumTile = {
|
export type AlbumTile = {
|
||||||
colStart: number;
|
/** Fractions (0..1) of the album box. */
|
||||||
colEnd: number;
|
left: number;
|
||||||
rowStart: number;
|
top: number;
|
||||||
rowEnd: number;
|
width: number;
|
||||||
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AlbumLayout = {
|
export type AlbumLayout = {
|
||||||
gridTemplateColumns: string;
|
|
||||||
gridTemplateRows: string;
|
|
||||||
/** width / height of the whole album box. */
|
/** width / height of the whole album box. */
|
||||||
aspectRatio: number;
|
aspectRatio: number;
|
||||||
tiles: AlbumTile[];
|
tiles: AlbumTile[];
|
||||||
@@ -38,10 +39,6 @@ function clampRatio(ratio: number | undefined): number {
|
|||||||
return Math.min(2, Math.max(0.55, ratio));
|
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 {
|
function layoutTwo(ratios: number[]): AlbumLayout {
|
||||||
const [r0, r1] = ratios;
|
const [r0, r1] = ratios;
|
||||||
const bothLandscape = r0 >= 1 && r1 >= 1;
|
const bothLandscape = r0 >= 1 && r1 >= 1;
|
||||||
@@ -50,25 +47,23 @@ function layoutTwo(ratios: number[]): AlbumLayout {
|
|||||||
// Stacked: equal width, heights follow each ratio.
|
// Stacked: equal width, heights follow each ratio.
|
||||||
const h0 = 1 / r0;
|
const h0 = 1 / r0;
|
||||||
const h1 = 1 / r1;
|
const h1 = 1 / r1;
|
||||||
|
const total = h0 + h1;
|
||||||
return {
|
return {
|
||||||
gridTemplateColumns: "1fr",
|
aspectRatio: 1 / total,
|
||||||
gridTemplateRows: fr([h0, h1]),
|
|
||||||
aspectRatio: 1 / (h0 + h1),
|
|
||||||
tiles: [
|
tiles: [
|
||||||
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 },
|
{ left: 0, top: 0, width: 1, height: h0 / total },
|
||||||
{ colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 },
|
{ left: 0, top: h0 / total, width: 1, height: h1 / total },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Portrait or mixed: side by side, equal height, widths follow each ratio.
|
// Portrait or mixed: side by side, equal height, widths follow each ratio.
|
||||||
|
const w0 = r0 / (r0 + r1);
|
||||||
return {
|
return {
|
||||||
gridTemplateColumns: fr([r0, r1]),
|
|
||||||
gridTemplateRows: "1fr",
|
|
||||||
aspectRatio: r0 + r1,
|
aspectRatio: r0 + r1,
|
||||||
tiles: [
|
tiles: [
|
||||||
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 },
|
{ left: 0, top: 0, width: w0, height: 1 },
|
||||||
{ colStart: 2, colEnd: 3, rowStart: 1, rowEnd: 2 },
|
{ 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
|
const primaryOnLeft = r0 < 1; // portrait primary -> left; landscape -> top
|
||||||
|
|
||||||
if (primaryOnLeft) {
|
if (primaryOnLeft) {
|
||||||
// Left column holds the primary (spans all rows). Right column is split
|
// Total height = 1. Left column holds the primary (full height); right
|
||||||
// into one row per secondary image; share width `wr`, heights follow ratio.
|
// column is split into one row per secondary image (share width `wr`).
|
||||||
const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0);
|
const invSum = secondary.reduce((acc, r) => acc + 1 / r, 0);
|
||||||
const wr = 1 / invSum; // right column width when total height = 1
|
const wrPx = 1 / invSum; // right-column width in box-height units
|
||||||
const wl = r0; // primary width when height = 1
|
const total = r0 + wrPx; // box width when box height = 1 (== aspectRatio)
|
||||||
const tiles: AlbumTile[] = [
|
const wl = r0 / total;
|
||||||
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: secondary.length + 1 },
|
const wr = wrPx / total;
|
||||||
];
|
const tiles: AlbumTile[] = [{ left: 0, top: 0, width: wl, height: 1 }];
|
||||||
secondary.forEach((_, i) => {
|
let y = 0;
|
||||||
tiles.push({ colStart: 2, colEnd: 3, rowStart: i + 1, rowEnd: i + 2 });
|
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 {
|
return { aspectRatio: total, tiles };
|
||||||
gridTemplateColumns: fr([wl, wr]),
|
|
||||||
gridTemplateRows: fr(secondary.map((r) => 1 / r)),
|
|
||||||
aspectRatio: wl + wr,
|
|
||||||
tiles,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary on top (spans all columns). Bottom row holds the secondary images
|
// Total width = 1. Primary on top (full width); secondary share a bottom row
|
||||||
// side by side; share height `hb`, widths follow ratio.
|
// (common height `hb`, widths follow ratio).
|
||||||
const sumR = secondary.reduce((acc, r) => acc + r, 0);
|
const sumR = secondary.reduce((acc, r) => acc + r, 0);
|
||||||
const ht = 1 / r0; // primary height when total width = 1
|
const ht = 1 / r0;
|
||||||
const hb = 1 / sumR; // bottom row height when total width = 1
|
const hb = 1 / sumR;
|
||||||
|
const total = ht + hb; // box height when width = 1
|
||||||
const tiles: AlbumTile[] = [
|
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) => {
|
let x = 0;
|
||||||
tiles.push({ colStart: i + 1, colEnd: i + 2, rowStart: 2, rowEnd: 3 });
|
secondary.forEach((r) => {
|
||||||
|
const w = r / sumR;
|
||||||
|
tiles.push({ left: x, top: ht / total, width: w, height: hb / total });
|
||||||
|
x += w;
|
||||||
});
|
});
|
||||||
return {
|
return { aspectRatio: 1 / total, tiles };
|
||||||
gridTemplateColumns: fr(secondary.map((r) => r)),
|
|
||||||
gridTemplateRows: fr([ht, hb]),
|
|
||||||
aspectRatio: 1 / (ht + hb),
|
|
||||||
tiles,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user