terry-media-adaptive-trial #10
@@ -11,6 +11,12 @@ type BubbleImageProps = {
|
|||||||
fallbackSrc?: string | (string | undefined)[];
|
fallbackSrc?: string | (string | undefined)[];
|
||||||
className?: string;
|
className?: string;
|
||||||
loading?: "lazy" | "eager";
|
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,
|
fallbackSrc,
|
||||||
className,
|
className,
|
||||||
loading,
|
loading,
|
||||||
|
onNaturalSize,
|
||||||
}: BubbleImageProps) {
|
}: BubbleImageProps) {
|
||||||
// Ordered, de-duplicated list of sources to attempt in turn.
|
// Ordered, de-duplicated list of sources to attempt in turn.
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
@@ -62,6 +69,11 @@ export function BubbleImage({
|
|||||||
alt=""
|
alt=""
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className={className}
|
className={className}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const img = e.currentTarget;
|
||||||
|
if (img.naturalWidth && img.naturalHeight)
|
||||||
|
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
|
||||||
|
}}
|
||||||
onError={() => setAttempt((i) => i + 1)}
|
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,25 +1,17 @@
|
|||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
|
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { BubbleImage } from "../BubbleImage";
|
import { BubbleImage } from "../BubbleImage";
|
||||||
|
import { useImageRatios } from "../hooks/useImageRatios";
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
import { useLightbox } from "../overlays/ImageLightbox";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
|
import { computeAlbumLayout } from "../utils/albumLayout";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
import { CollapsibleText } from "../CollapsibleText";
|
||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
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 }) {
|
export function AlbumBubble({ post }: { post: Post }) {
|
||||||
const { openLightbox } = useLightbox();
|
const { openLightbox } = useLightbox();
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
@@ -27,20 +19,39 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
const text = postDisplayText(post, lang);
|
const text = postDisplayText(post, lang);
|
||||||
const visible = images.slice(0, MAX_VISIBLE);
|
const visible = images.slice(0, MAX_VISIBLE);
|
||||||
const extra = images.length - 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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className={`${albumGridClass(layoutCount)} gap-px overflow-hidden`}>
|
{/* 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). */}
|
||||||
|
<div
|
||||||
|
className="relative w-full overflow-hidden bg-black"
|
||||||
|
style={{
|
||||||
|
aspectRatio: layout?.aspectRatio,
|
||||||
|
maxHeight: ALBUM_MAX_HEIGHT,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{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];
|
||||||
|
if (!tile) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={att.id}
|
key={att.id}
|
||||||
className={`relative h-full w-full overflow-hidden ${albumItemClass(
|
className="absolute overflow-hidden"
|
||||||
i,
|
style={{
|
||||||
layoutCount,
|
left: `${tile.left * 100}%`,
|
||||||
)}`}
|
top: `${tile.top * 100}%`,
|
||||||
|
width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`,
|
||||||
|
height: `calc(${tile.height * 100}% - ${ALBUM_GAP}px)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -51,7 +62,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<BubbleImage
|
<BubbleImage
|
||||||
src={att.thumbnailUrl ?? att.thumbUrl ?? att.url}
|
src={sources[i]}
|
||||||
fallbackSrc={[att.thumbUrl, att.url]}
|
fallbackSrc={[att.thumbUrl, att.url]}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
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 type { Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { SingleImageFrame } from "./SingleImageFrame";
|
||||||
import { BubbleImage } from "../BubbleImage";
|
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
|
||||||
|
|
||||||
export function ImageBubble({ post }: { post: Post }) {
|
export function ImageBubble({ post }: { post: Post }) {
|
||||||
const { openLightbox } = useLightbox();
|
|
||||||
const att = post.attachments[0];
|
const att = post.attachments[0];
|
||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
return (
|
return <SingleImageFrame postId={post.id} attachment={att} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Post } from "../../../types/post";
|
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 { autolink } from "../utils/autolink";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
import { CollapsibleText } from "../CollapsibleText";
|
||||||
|
import { SingleImageFrame } from "./SingleImageFrame";
|
||||||
|
|
||||||
export function ImageWithTextBubble({ post }: { post: Post }) {
|
export function ImageWithTextBubble({ post }: { post: Post }) {
|
||||||
const { openLightbox } = useLightbox();
|
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
const att = post.attachments[0];
|
const att = post.attachments[0];
|
||||||
const text = postDisplayText(post, lang);
|
const text = postDisplayText(post, lang);
|
||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<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]">
|
<SingleImageFrame postId={post.id} attachment={att} text={text} />
|
||||||
<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>
|
|
||||||
{text ? (
|
{text ? (
|
||||||
<CollapsibleText
|
<CollapsibleText
|
||||||
wrapperClassName="px-4 pt-3"
|
wrapperClassName="px-4 pt-3"
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
59
src/components/messageStream/utils/albumLayout.test.ts
Normal file
59
src/components/messageStream/utils/albumLayout.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
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", () => {
|
||||||
|
expect(computeAlbumLayout([])).toBeNull();
|
||||||
|
expect(computeAlbumLayout([1.5])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places two portraits side by side, splitting the full width", () => {
|
||||||
|
const layout = computeAlbumLayout([0.7, 0.8])!;
|
||||||
|
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, splitting the full height", () => {
|
||||||
|
const layout = computeAlbumLayout([1.6, 1.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("portrait primary on the left, rest stacked on the right, fully covering", () => {
|
||||||
|
const layout = computeAlbumLayout([0.6, 1.2, 1.3])!;
|
||||||
|
// 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("landscape primary on top, rest in a bottom row, fully covering", () => {
|
||||||
|
const layout = computeAlbumLayout([1.8, 0.9, 1.1])!;
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
src/components/messageStream/utils/albumLayout.ts
Normal file
123
src/components/messageStream/utils/albumLayout.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Telegram-style adaptive album layout.
|
||||||
|
*
|
||||||
|
* Given the aspect ratios (width / height) of the images in an album, computes
|
||||||
|
* 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 (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
|
||||||
|
* right, a landscape primary sits on TOP with the rest in a row below.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AlbumTile = {
|
||||||
|
/** Fractions (0..1) of the album box. */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlbumLayout = {
|
||||||
|
/** 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 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;
|
||||||
|
const total = h0 + h1;
|
||||||
|
return {
|
||||||
|
aspectRatio: 1 / total,
|
||||||
|
tiles: [
|
||||||
|
{ 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 {
|
||||||
|
aspectRatio: r0 + r1,
|
||||||
|
tiles: [
|
||||||
|
{ left: 0, top: 0, width: w0, height: 1 },
|
||||||
|
{ left: w0, top: 0, width: 1 - w0, height: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// 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 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 { aspectRatio: total, tiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const hb = 1 / sumR;
|
||||||
|
const total = ht + hb; // box height when width = 1
|
||||||
|
const tiles: AlbumTile[] = [
|
||||||
|
{ left: 0, top: 0, width: 1, height: ht / total },
|
||||||
|
];
|
||||||
|
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 { aspectRatio: 1 / total, 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