@@ -181,6 +181,8 @@ width: 100% !important; height: auto !important; max-width: 100%; + max-height: 800px; + object-fit: contain; border-radius: 16px; } @@ -21,6 +21,7 @@ flex-direction: row; flex-wrap: nowrap; align-items: stretch; + justify-content: flex-start; width: 100%; box-sizing: border-box; } @@ -29,6 +30,7 @@ position: relative; display: block; flex: 0 0 auto; + min-width: 0; box-sizing: border-box; border-radius: 4px; overflow: hidden; @@ -39,8 +39,18 @@ export const MediaPageContentPaginated = ({ } return map }, [items]) + const urlsByUid = useMemo(() => { + const map: Record = {} + for (const message of items ?? []) { + const file = message.file?.toString() + if (file) { + map[message.uid] = file + } + } + return map + }, [items]) - const { gap, rows, setAspect } = useJustifiedMediaGrid(uids, gridRef) + const { gap, rows, setAspect } = useJustifiedMediaGrid(uids, urlsByUid, gridRef) useEffect(() => { if (!hasMore || !items?.length || loading) { @@ -128,7 +138,11 @@ export const MediaPageContentPaginated = ({
Эта генерация является архивом @@ -142,15 +156,19 @@ export const MediaPageContentPaginated = ({
{file && ( {message.content openModal(file)} @@ -1,7 +1,11 @@ -import { useCallback, useLayoutEffect, useMemo, useState, type RefObject } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useState, type RefObject } from 'react' const GAP = 4 -const TARGET_ROW_HEIGHT = 260 +const TARGET_ROW_HEIGHT = 280 +const MIN_TILE_WIDTH = 250 +const MIN_TILE_HEIGHT = 140 +const MAX_TILE_HEIGHT = 800 +const MAX_WIDTH_STRETCH = 1.06 const DEFAULT_ASPECT = 1 export type JustifiedLayoutItem = { @@ -17,7 +21,18 @@ export type JustifiedLayoutRow = { type AspectMap = Record -/** Round widths so their sum exactly equals `totalWidth` (no leftover flex holes). */ +function getAspect(aspects: AspectMap, uid: string) { + const aspect = aspects[uid] ?? DEFAULT_ASPECT + if (!Number.isFinite(aspect) || aspect <= 0) { + return DEFAULT_ASPECT + } + return aspect +} + +function minTileWidthForContainer(containerWidth: number) { + return Math.min(MIN_TILE_WIDTH, Math.max(1, containerWidth)) +} + function distributeWidths(rawWidths: number[], totalWidth: number): number[] { if (rawWidths.length === 0) { return [] @@ -42,11 +57,47 @@ function distributeWidths(rawWidths: number[], totalWidth: number): number[] { return result } +function rowFitsMins(uids: string[], aspects: AspectMap, containerWidth: number) { + if (uids.length === 0) { + return true + } + + const minWidth = minTileWidthForContainer(containerWidth) + const gaps = GAP * Math.max(uids.length - 1, 0) + const availableWidth = Math.max(Math.floor(containerWidth) - gaps, uids.length) + const aspectSum = uids.reduce((sum, uid) => sum + getAspect(aspects, uid), 0) + const height = availableWidth / aspectSum + + if (height < MIN_TILE_HEIGHT) { + return false + } + + return uids.every((uid) => height * getAspect(aspects, uid) >= minWidth - 0.5) +} + +function resolveRowHeight(availableWidth: number, aspectSum: number) { + const fillHeight = Math.max(MIN_TILE_HEIGHT, availableWidth / aspectSum) + + if (fillHeight <= MAX_TILE_HEIGHT) { + return fillHeight + } + + const stretch = availableWidth / (MAX_TILE_HEIGHT * aspectSum) + if (stretch <= MAX_WIDTH_STRETCH) { + return MAX_TILE_HEIGHT + } + + return fillHeight +} + + function buildJustifiedRows(uids: string[], aspects: AspectMap, containerWidth: number): JustifiedLayoutRow[] { if (!uids.length || containerWidth <= 0) { return [] } + const minWidth = minTileWidthForContainer(containerWidth) + const maxItemsInRow = Math.max(1, Math.floor((containerWidth + GAP) / (minWidth + GAP))) const rows: JustifiedLayoutRow[] = [] let row: string[] = [] let rowAspectSum = 0 @@ -58,16 +109,31 @@ function buildJustifiedRows(uids: string[], aspects: AspectMap, containerWidth: const gaps = GAP * Math.max(row.length - 1, 0) const availableWidth = Math.max(Math.floor(containerWidth) - gaps, row.length) - const height = availableWidth / rowAspectSum - const rawWidths = row.map((uid) => height * (aspects[uid] ?? DEFAULT_ASPECT)) + const height = resolveRowHeight(availableWidth, rowAspectSum) + + let rawWidths = row.map((uid) => height * getAspect(aspects, uid)) + let widthSum = rawWidths.reduce((sum, width) => sum + width, 0) + + if (Math.abs(widthSum - availableWidth) > 0.5) { + const scale = availableWidth / widthSum + rawWidths = rawWidths.map((width) => width * scale) + } + + rawWidths = rawWidths.map((width) => Math.max(minWidth, width)) + widthSum = rawWidths.reduce((sum, width) => sum + width, 0) + if (widthSum > availableWidth) { + const scale = availableWidth / widthSum + rawWidths = rawWidths.map((width) => Math.max(1, width * scale)) + } + const widths = distributeWidths(rawWidths, availableWidth) - const rowHeight = Math.max(1, Math.round(height)) + const rowHeight = Math.max(MIN_TILE_HEIGHT, Math.round(height)) rows.push({ height: rowHeight, items: row.map((uid, index) => ({ uid, - width: widths[index], + width: Math.max(1, widths[index]), height: rowHeight, })), }) @@ -77,20 +143,33 @@ function buildJustifiedRows(uids: string[], aspects: AspectMap, containerWidth: } for (const uid of uids) { - const aspect = aspects[uid] ?? DEFAULT_ASPECT + const aspect = getAspect(aspects, uid) + const nextRow = [...row, uid] + + const wouldBreakMins = row.length > 0 && !rowFitsMins(nextRow, aspects, containerWidth) + const wouldExceedCount = row.length >= maxItemsInRow + + if (wouldBreakMins || wouldExceedCount) { + flushRow() + } + row.push(uid) rowAspectSum += aspect const gaps = GAP * Math.max(row.length - 1, 0) + const availableWidth = Math.max(Math.floor(containerWidth) - gaps, row.length) + const fillHeight = availableWidth / rowAspectSum const rowWidthAtTarget = rowAspectSum * TARGET_ROW_HEIGHT + gaps - // Row is full enough — scale all items in it to exactly fill container width. - if (rowWidthAtTarget >= containerWidth) { + const fullAtTarget = rowWidthAtTarget >= containerWidth + const withinMaxHeight = fillHeight <= MAX_TILE_HEIGHT + const hitCountCap = row.length >= maxItemsInRow + + if (hitCountCap || (fullAtTarget && withinMaxHeight && rowFitsMins(row, aspects, containerWidth))) { flushRow() } } - // Last row also stretches to full width (no trailing hole). if (row.length > 0) { flushRow() } @@ -98,7 +177,11 @@ function buildJustifiedRows(uids: string[], aspects: AspectMap, containerWidth: return rows } -export function useJustifiedMediaGrid(uids: string[], containerRef: RefObject) { +export function useJustifiedMediaGrid( + uids: string[], + urlsByUid: Record, + containerRef: RefObject +) { const [containerWidth, setContainerWidth] = useState(0) const [aspects, setAspects] = useState({}) @@ -119,7 +202,6 @@ export function useJustifiedMediaGrid(uids: string[], containerRef: RefObject observer.disconnect() - // Re-bind when items appear: grid is unmounted during the initial loading state. }, [containerRef, uids.length]) const setAspect = useCallback((uid: string, width: number, height: number) => { @@ -136,6 +218,30 @@ export function useJustifiedMediaGrid(uids: string[], containerRef: RefObject { + let cancelled = false + + for (const uid of uids) { + const url = urlsByUid[uid] + if (!url || url.includes('.zip')) { + continue + } + + const img = new window.Image() + img.onload = () => { + if (!cancelled && img.naturalWidth && img.naturalHeight) { + setAspect(uid, img.naturalWidth, img.naturalHeight) + } + } + img.src = url + } + + return () => { + cancelled = true + } + }, [uids, urlsByUid, setAspect]) + const rows = useMemo( () => buildJustifiedRows(uids, aspects, containerWidth), [uids, aspects, containerWidth]