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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user