- 单图气泡按真实比例显示:横图限高260px、过高裁上下;竖图完整铺满宽度不裁、无黑边 - 2张同类相册:都竖图左右并排、都横图上下堆叠,按比例不裁 - 3+张相册:Telegram式马赛克拼贴(竖主图占左+其余堆右 / 横主图占顶+其余排底) - 图片比例优先用后端width/height,缺失时从加载后的naturalWidth/Height读取 - 新增 constants/media.ts 统一尺寸规范;albumLayout 纯算法附单测 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
2.7 KiB
TypeScript
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>
|
|
);
|
|
}
|