terry-media-adaptive-trial #10
@@ -11,6 +11,12 @@ type BubbleImageProps = {
|
||||
fallbackSrc?: string | (string | undefined)[];
|
||||
className?: string;
|
||||
loading?: "lazy" | "eager";
|
||||
/**
|
||||
* Called once the active source loads, with the image's intrinsic pixel
|
||||
* size. Lets callers (e.g. single-image bubbles) adopt the real aspect
|
||||
* ratio without depending on backend-provided width/height.
|
||||
*/
|
||||
onNaturalSize?: (width: number, height: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,6 +30,7 @@ export function BubbleImage({
|
||||
fallbackSrc,
|
||||
className,
|
||||
loading,
|
||||
onNaturalSize,
|
||||
}: BubbleImageProps) {
|
||||
// Ordered, de-duplicated list of sources to attempt in turn.
|
||||
const candidates = useMemo(() => {
|
||||
@@ -62,6 +69,11 @@ export function BubbleImage({
|
||||
alt=""
|
||||
loading={loading}
|
||||
className={className}
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth && img.naturalHeight)
|
||||
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
|
||||
}}
|
||||
onError={() => setAttempt((i) => i + 1)}
|
||||
/>
|
||||
);
|
||||
|
||||
85
src/components/messageStream/bubbles/AdaptiveImageFrame.tsx
Normal file
85
src/components/messageStream/bubbles/AdaptiveImageFrame.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import {
|
||||
SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS,
|
||||
SINGLE_IMAGE_MAX_HEIGHT,
|
||||
} from "../../../constants/media";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
|
||||
/**
|
||||
* Shared frame that sizes an image to its real aspect ratio. The image always
|
||||
* fills the full width (never any left/right black bars). Behaviour by
|
||||
* orientation:
|
||||
* - Landscape/square: height capped at SINGLE_IMAGE_MAX_HEIGHT; images taller
|
||||
* than the cap are cropped top/bottom (object-cover).
|
||||
* - Portrait (height > width): no height cap — the frame grows to the real
|
||||
* ratio so the whole image shows, uncropped and without side bars (the
|
||||
* bubble is correspondingly tall).
|
||||
* Used by single-image bubbles and 2-image albums.
|
||||
*
|
||||
* The ratio is taken from backend width/height when present (no layout shift),
|
||||
* then refined from the loaded image's intrinsic size so it works even when the
|
||||
* backend omits those fields. See `constants/media.ts`.
|
||||
*/
|
||||
export function AdaptiveImageFrame({
|
||||
attachment,
|
||||
src,
|
||||
fallbackSrc,
|
||||
onOpen,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
/** Display source; defaults to the attachment's full url. */
|
||||
src?: string;
|
||||
fallbackSrc?: (string | undefined)[];
|
||||
onOpen: () => void;
|
||||
ariaLabel: string;
|
||||
/** Overlays rendered on top of the image (download pill, "+N", etc.). */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const [ratio, setRatio] = useState<number | undefined>(
|
||||
attachment.width && attachment.height
|
||||
? attachment.width / attachment.height
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const isPortrait = ratio !== undefined && ratio < 1;
|
||||
|
||||
let frameStyle: CSSProperties | undefined;
|
||||
if (ratio !== undefined) {
|
||||
frameStyle = isPortrait
|
||||
? // Portrait: follow the real ratio with no cap → full image, full width,
|
||||
// no crop, no side bars (the frame is tall).
|
||||
{ aspectRatio: String(ratio) }
|
||||
: // Landscape/square: real ratio, capped height, cropped top/bottom past it.
|
||||
{ aspectRatio: String(ratio), maxHeight: SINGLE_IMAGE_MAX_HEIGHT };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative w-full overflow-hidden bg-black ${
|
||||
ratio ? "" : SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS
|
||||
}`}
|
||||
style={frameStyle}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="block h-full w-full"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<BubbleImage
|
||||
src={src ?? attachment.url}
|
||||
fallbackSrc={fallbackSrc}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
onNaturalSize={(w, h) => {
|
||||
if (w && h) setRatio(w / h);
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,16 @@
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { useImageRatios } from "../hooks/useImageRatios";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { computeAlbumLayout } from "../utils/albumLayout";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
function albumGridClass(count: number) {
|
||||
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
|
||||
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
|
||||
return `${height} grid grid-cols-2 grid-rows-2`;
|
||||
}
|
||||
|
||||
function albumItemClass(index: number, count: number) {
|
||||
if (count === 3 && index === 0) return "row-span-2";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function AlbumBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const { lang } = useI18n();
|
||||
@@ -26,20 +18,40 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
const text = postDisplayText(post, lang);
|
||||
const visible = images.slice(0, MAX_VISIBLE);
|
||||
const extra = images.length - MAX_VISIBLE;
|
||||
const layoutCount = Math.min(images.length, MAX_VISIBLE);
|
||||
|
||||
const sources = visible.map(
|
||||
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
|
||||
);
|
||||
const ratios = useImageRatios(visible, sources);
|
||||
const layout = computeAlbumLayout(ratios);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className={`${albumGridClass(layoutCount)} gap-px overflow-hidden`}>
|
||||
<div
|
||||
className="grid overflow-hidden bg-black"
|
||||
style={{
|
||||
gap: ALBUM_GAP,
|
||||
gridTemplateColumns: layout?.gridTemplateColumns,
|
||||
gridTemplateRows: layout?.gridTemplateRows,
|
||||
aspectRatio: layout?.aspectRatio,
|
||||
maxHeight: ALBUM_MAX_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{visible.map((att, i) => {
|
||||
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
|
||||
const tile = layout?.tiles[i];
|
||||
return (
|
||||
<div
|
||||
key={att.id}
|
||||
className={`relative h-full w-full overflow-hidden ${albumItemClass(
|
||||
i,
|
||||
layoutCount,
|
||||
)}`}
|
||||
className="relative overflow-hidden"
|
||||
style={
|
||||
tile
|
||||
? {
|
||||
gridColumn: `${tile.colStart} / ${tile.colEnd}`,
|
||||
gridRow: `${tile.rowStart} / ${tile.rowEnd}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -50,7 +62,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
||||
}
|
||||
>
|
||||
<BubbleImage
|
||||
src={att.thumbnailUrl ?? att.thumbUrl ?? att.url}
|
||||
src={sources[i]}
|
||||
fallbackSrc={[att.thumbUrl, att.url]}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import type { Post } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { SingleImageFrame } from "./SingleImageFrame";
|
||||
|
||||
export function ImageBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const att = post.attachments[0];
|
||||
if (!att) return null;
|
||||
return (
|
||||
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox([att], 0, undefined, post.id)}
|
||||
className="block h-full w-full"
|
||||
aria-label="View image"
|
||||
>
|
||||
<BubbleImage
|
||||
src={att.url}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<AttachmentDownloadPill postId={post.id} attachment={att} />
|
||||
</div>
|
||||
);
|
||||
return <SingleImageFrame postId={post.id} attachment={att} />;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import { useI18n } from "../../../i18n";
|
||||
import type { Post } from "../../../types/post";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { postDisplayText } from "../utils/postText";
|
||||
import { SingleImageFrame } from "./SingleImageFrame";
|
||||
|
||||
export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||
const { openLightbox } = useLightbox();
|
||||
const { lang } = useI18n();
|
||||
const att = post.attachments[0];
|
||||
const text = postDisplayText(post, lang);
|
||||
if (!att) return null;
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox([att], 0, text, post.id)}
|
||||
className="block h-full w-full"
|
||||
aria-label="View image"
|
||||
>
|
||||
<BubbleImage
|
||||
src={att.url}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<AttachmentDownloadPill postId={post.id} attachment={att} />
|
||||
</div>
|
||||
<SingleImageFrame postId={post.id} attachment={att} text={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">
|
||||
{autolink(text)}
|
||||
|
||||
29
src/components/messageStream/bubbles/SingleImageFrame.tsx
Normal file
29
src/components/messageStream/bubbles/SingleImageFrame.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { useLightbox } from "../overlays/ImageLightbox";
|
||||
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
|
||||
|
||||
/**
|
||||
* A single image inside a message bubble. Shown at its real aspect ratio (no
|
||||
* top/bottom cropping) via {@link AdaptiveImageFrame}.
|
||||
*/
|
||||
export function SingleImageFrame({
|
||||
postId,
|
||||
attachment,
|
||||
text,
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
text?: string;
|
||||
}) {
|
||||
const { openLightbox } = useLightbox();
|
||||
return (
|
||||
<AdaptiveImageFrame
|
||||
attachment={attachment}
|
||||
onOpen={() => openLightbox([attachment], 0, text, postId)}
|
||||
ariaLabel="View image"
|
||||
>
|
||||
<AttachmentDownloadPill postId={postId} attachment={attachment} />
|
||||
</AdaptiveImageFrame>
|
||||
);
|
||||
}
|
||||
47
src/components/messageStream/hooks/useImageRatios.ts
Normal file
47
src/components/messageStream/hooks/useImageRatios.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
|
||||
/**
|
||||
* Resolves each attachment's aspect ratio (width / height). Seeds from
|
||||
* backend-provided width/height when present (no flash), then refines by
|
||||
* loading the image and reading its intrinsic size — so it works even when the
|
||||
* backend omits dimensions. Entries are `undefined` until known.
|
||||
*/
|
||||
export function useImageRatios(
|
||||
attachments: Attachment[],
|
||||
sources: (string | undefined)[],
|
||||
): (number | undefined)[] {
|
||||
const seed = attachments.map((a) =>
|
||||
a.width && a.height ? a.width / a.height : undefined,
|
||||
);
|
||||
const [ratios, setRatios] = useState<(number | undefined)[]>(seed);
|
||||
|
||||
const key = sources.join("|");
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRatios(
|
||||
attachments.map((a) =>
|
||||
a.width && a.height ? a.width / a.height : undefined,
|
||||
),
|
||||
);
|
||||
sources.forEach((src, i) => {
|
||||
if (!src) return;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled || !img.naturalWidth || !img.naturalHeight) return;
|
||||
setRatios((prev) => {
|
||||
const next = prev.slice();
|
||||
next[i] = img.naturalWidth / img.naturalHeight;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [key]);
|
||||
|
||||
return ratios;
|
||||
}
|
||||
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);
|
||||
}
|
||||
39
src/constants/media.ts
Normal file
39
src/constants/media.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Media sizing spec (single source of truth).
|
||||
*
|
||||
* Goal: show images at their real aspect ratio (no top/bottom cropping) while
|
||||
* keeping them bounded so an oversized upload can't take over the screen.
|
||||
*
|
||||
* Width is intentionally NOT touched here: a single image still fills 100% of
|
||||
* the bubble, whose width stays bounded by the existing message-container
|
||||
* max-widths. Only the height adapts (and is clamped).
|
||||
*
|
||||
* How it's applied to a single-image bubble (behaviour depends on orientation):
|
||||
* - Landscape / square (width >= height): the frame fills the full width and
|
||||
* follows the real ratio, capped at SINGLE_IMAGE_MAX_HEIGHT. Taller-than-cap
|
||||
* images are cropped top/bottom (object-cover) — never any left/right bars.
|
||||
* - Portrait (height > width): no height cap. The frame follows the real ratio
|
||||
* so the whole image shows — full width, uncropped, no side bars — which
|
||||
* makes the bubble tall. (Product chose "complete image" over "compact".)
|
||||
* - When width/height are unknown (no backend value and not yet loaded), we
|
||||
* fall back to the legacy fixed heights below so layout stays predictable.
|
||||
*/
|
||||
|
||||
/** Height cap for landscape/square images (cropped top/bottom beyond this). */
|
||||
export const SINGLE_IMAGE_MAX_HEIGHT = 260;
|
||||
|
||||
/**
|
||||
* Telegram-style multi-image albums. Cells are sized to each image's real
|
||||
* ratio; the whole mosaic is capped so a tall portrait-led album stays
|
||||
* reasonable (cells crop slightly via object-cover beyond the cap).
|
||||
*/
|
||||
export const ALBUM_MAX_HEIGHT = 420;
|
||||
/** Gap between album tiles, in px. */
|
||||
export const ALBUM_GAP = 3;
|
||||
|
||||
/**
|
||||
* Legacy responsive heights, used only when an attachment has no width/height.
|
||||
* Mirrors the previous fixed-height behaviour.
|
||||
*/
|
||||
export const SINGLE_IMAGE_FALLBACK_HEIGHT_CLASS =
|
||||
"h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]";
|
||||
Reference in New Issue
Block a user