Files
Arkie-Library-Frontend/src/components/messageStream/bubbles/AdaptiveImageFrame.tsx
TerryM 0035457c6d 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>
2026-05-29 22:16:55 +08:00

86 lines
2.7 KiB
TypeScript

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>
);
}