feat: 媒体流图片自适应显示(单图/2图/Telegram式相册)
- 单图气泡按真实比例显示:横图限高260px、过高裁上下;竖图完整铺满宽度不裁、无黑边 - 2张同类相册:都竖图左右并排、都横图上下堆叠,按比例不裁 - 3+张相册:Telegram式马赛克拼贴(竖主图占左+其余堆右 / 横主图占顶+其余排底) - 图片比例优先用后端width/height,缺失时从加载后的naturalWidth/Height读取 - 新增 constants/media.ts 统一尺寸规范;albumLayout 纯算法附单测 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
54
src/components/messageStream/utils/albumLayout.test.ts
Normal file
54
src/components/messageStream/utils/albumLayout.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeAlbumLayout } from "./albumLayout";
|
||||
|
||||
describe("computeAlbumLayout", () => {
|
||||
it("returns null for fewer than 2 images", () => {
|
||||
expect(computeAlbumLayout([])).toBeNull();
|
||||
expect(computeAlbumLayout([1.5])).toBeNull();
|
||||
});
|
||||
|
||||
it("places two portraits side by side (1 row, 2 cols)", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("stacks two landscapes vertically (2 rows, 1 col)", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it("puts a portrait primary on the left with the rest stacked right", () => {
|
||||
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 });
|
||||
});
|
||||
|
||||
it("puts a landscape primary on top with the rest in a row below", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
131
src/components/messageStream/utils/albumLayout.ts
Normal file
131
src/components/messageStream/utils/albumLayout.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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, 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
|
||||
* right, a landscape primary sits on TOP with the rest in a row below.
|
||||
*/
|
||||
|
||||
export type AlbumTile = {
|
||||
colStart: number;
|
||||
colEnd: number;
|
||||
rowStart: number;
|
||||
rowEnd: number;
|
||||
};
|
||||
|
||||
export type AlbumLayout = {
|
||||
gridTemplateColumns: string;
|
||||
gridTemplateRows: string;
|
||||
/** width / height of the whole album box. */
|
||||
aspectRatio: number;
|
||||
tiles: AlbumTile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep ratios within a sane range so one extreme image can't make the whole
|
||||
* mosaic absurdly tall or flat. Beyond this the cell crops (object-cover).
|
||||
*/
|
||||
function clampRatio(ratio: number | undefined): number {
|
||||
if (!ratio || !Number.isFinite(ratio) || ratio <= 0) return 1;
|
||||
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;
|
||||
|
||||
if (bothLandscape) {
|
||||
// Stacked: equal width, heights follow each ratio.
|
||||
const h0 = 1 / r0;
|
||||
const h1 = 1 / r1;
|
||||
return {
|
||||
gridTemplateColumns: "1fr",
|
||||
gridTemplateRows: fr([h0, h1]),
|
||||
aspectRatio: 1 / (h0 + h1),
|
||||
tiles: [
|
||||
{ colStart: 1, colEnd: 2, rowStart: 1, rowEnd: 2 },
|
||||
{ colStart: 1, colEnd: 2, rowStart: 2, rowEnd: 3 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Portrait or mixed: side by side, equal height, widths follow each ratio.
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout {
|
||||
const r0 = ratios[0];
|
||||
const secondary = ratios.slice(1);
|
||||
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.
|
||||
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 });
|
||||
});
|
||||
return {
|
||||
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
|
||||
// side by side; share 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 tiles: AlbumTile[] = [
|
||||
{ colStart: 1, colEnd: secondary.length + 1, rowStart: 1, rowEnd: 2 },
|
||||
];
|
||||
secondary.forEach((_, i) => {
|
||||
tiles.push({ colStart: i + 1, colEnd: i + 2, rowStart: 2, rowEnd: 3 });
|
||||
});
|
||||
return {
|
||||
gridTemplateColumns: fr(secondary.map((r) => r)),
|
||||
gridTemplateRows: fr([ht, hb]),
|
||||
aspectRatio: 1 / (ht + hb),
|
||||
tiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the mosaic for the given image ratios (already sliced to the visible
|
||||
* count, max 4). Returns null for counts this layout doesn't handle (0 or 1).
|
||||
*/
|
||||
export function computeAlbumLayout(
|
||||
rawRatios: (number | undefined)[],
|
||||
): AlbumLayout | null {
|
||||
const ratios = rawRatios.map(clampRatio);
|
||||
if (ratios.length < 2) return null;
|
||||
if (ratios.length === 2) return layoutTwo(ratios);
|
||||
return layoutPrimaryPlusLine(ratios);
|
||||
}
|
||||
Reference in New Issue
Block a user