mirror of
https://github.com/rommapp/romm.git
synced 2026-06-29 07:16:28 +00:00
feat(v2): render cover art at its natural aspect ratio
Stop forcing a fixed box ratio on gallery covers. Cards now share one height and vary in width to match each cover image's natural aspect, with no cropping. - GameCover measures the rendered image's natural ratio on load and drives its own aspect-ratio from it (style ratio kept only as a pre-load seed); emits the measured ratio. - GameCard default (gallery) card: fixed art height, natural width; size tiers and hero keep their fixed footprints. Forwards the ratio event. - Gallery grid becomes flow-packed wrapping rows: useGalleryVirtualItems greedily packs same-height / natural-width cards per row (ragged right), measuring ratios client-side (cached by rom id, debounced re-pack). Row height stays uniform so RVirtualScroller, AlphaStrip, scroll restoration and grid-nav are unchanged. - useResponsiveColumns additionally exposes usableWidth for width packing. - Unit tests for packFlowRows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -242,19 +242,21 @@ const { groupBy, layout, toolbarPosition } = useGalleryMode();
|
||||
// CSS grid `minmax(--r-card-art-w, 1fr)` stay in lock-step.
|
||||
const { xs, smAndDown } = useBreakpoint();
|
||||
const sectionEl = ref<HTMLElement | null>(null);
|
||||
// Single source for the responsive card-art width — feeds both the column
|
||||
// count and the virtualiser's row-height math so they never drift.
|
||||
const cardWidth = () => (xs.value ? 108 : 158);
|
||||
const { columns } = useResponsiveColumns(sectionEl, {
|
||||
// Card-art width reference (matches GameCard's `--r-card-art-w`). It sets
|
||||
// the card HEIGHT (a 2/3 cover's height at this width); each card's real
|
||||
// width then follows its cover's natural ratio.
|
||||
const CARD_GAP_PX = 12;
|
||||
const cardWidth = () => (xs.value ? 130 : 158);
|
||||
const cardHeight = () => Math.round(cardWidth() / (2 / 3));
|
||||
const { columns, usableWidth } = useResponsiveColumns(sectionEl, {
|
||||
cardWidth,
|
||||
gap: 12,
|
||||
gap: CARD_GAP_PX,
|
||||
inset: () => (xs.value ? 64 : smAndDown.value ? 76 : 108),
|
||||
});
|
||||
|
||||
// Active cover aspect ratio from the gallery-wide boxart style. Drives
|
||||
// both the cards' `--r-cover-ratio` (via the shell root, inherited) and
|
||||
// the virtualiser's row height so the cover shape, the grid, and the
|
||||
// scroll offsets all agree.
|
||||
// Fallback cover ratio from the gallery-wide boxart style — used only as
|
||||
// the per-card `--r-cover-ratio` seed (before GameCover measures the real
|
||||
// image) and for the bootstrap skeleton rows.
|
||||
const { boxartStyle } = useUISettings();
|
||||
const coverAspectRatio = computed(() =>
|
||||
coverRatio(
|
||||
@@ -262,6 +264,31 @@ const coverAspectRatio = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
// Measured natural cover ratios, keyed by rom id (stable across gallery
|
||||
// context switches, so the cache survives navigation). GameCard reports
|
||||
// each cover's ratio once its image loads; the flow-packer reads them via
|
||||
// `ratioAt(position)`. Updates are batched behind `ratioVersion` so a burst
|
||||
// of image loads triggers a single re-pack instead of one per cover.
|
||||
const ratioByRomId = new Map<number, number>();
|
||||
const ratioVersion = ref(0);
|
||||
let ratioBumpTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function onCardRatio(payload: { romId: number; ratio: number }) {
|
||||
const prev = ratioByRomId.get(payload.romId);
|
||||
// Ignore no-op / sub-pixel changes so we don't re-pack for nothing.
|
||||
if (prev != null && Math.abs(prev - payload.ratio) < 0.01) return;
|
||||
ratioByRomId.set(payload.romId, payload.ratio);
|
||||
if (ratioBumpTimer) return;
|
||||
ratioBumpTimer = setTimeout(() => {
|
||||
ratioBumpTimer = null;
|
||||
ratioVersion.value++;
|
||||
}, 150);
|
||||
}
|
||||
function ratioAt(position: number): number {
|
||||
const romId = galleryRoms.romIdIndex[position];
|
||||
if (romId == null) return 0; // → packer falls back to the default ratio
|
||||
return ratioByRomId.get(romId) ?? 0;
|
||||
}
|
||||
|
||||
// 2D arrow / gamepad nav for both layouts of the gallery. Two passes:
|
||||
// * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item
|
||||
// wrapper around the row's GameCards). ArrowLeft/Right within a row,
|
||||
@@ -301,8 +328,11 @@ const { virtualItems, letterToIndex, availableLetters, getItemHeight } =
|
||||
notFound: notFoundRef,
|
||||
notFoundMessage: notFoundMessageRef,
|
||||
skeletonRowCount: props.skeletonRowCount,
|
||||
coverRatio: coverAspectRatio,
|
||||
cardWidth,
|
||||
cardHeight,
|
||||
rowWidth: usableWidth,
|
||||
gap: CARD_GAP_PX,
|
||||
ratioAt,
|
||||
ratioVersion,
|
||||
});
|
||||
|
||||
const scrollerRef = ref<InstanceType<typeof RVirtualScroller> | null>(null);
|
||||
@@ -644,6 +674,7 @@ onBeforeUnmount(() => {
|
||||
gallerySelection.clear();
|
||||
if (searchDebounce) clearTimeout(searchDebounce);
|
||||
if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer);
|
||||
if (ratioBumpTimer) clearTimeout(ratioBumpTimer);
|
||||
// Cancel any per-position fetches still in flight for our last
|
||||
// visible set — `invalidateWindows` / `resetGallery` already aborts
|
||||
// window-level fetches; this covers per-card cleanup on unmount.
|
||||
@@ -682,10 +713,6 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem;
|
||||
const asListRow = (i: GalleryItem) => i as ListRowItem;
|
||||
const itemKind = (i: GalleryItem) => i.kind;
|
||||
|
||||
const rowGridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${Math.max(1, columns.value)}, minmax(var(--r-card-art-w), 1fr))`,
|
||||
}));
|
||||
|
||||
// View-facing surface. Methods only — internal state stays internal.
|
||||
defineExpose({
|
||||
/** Re-apply the previously-saved scroll position for the current route
|
||||
@@ -787,7 +814,6 @@ defineExpose({
|
||||
<div
|
||||
v-else-if="itemKind(item as GalleryItem) === 'row'"
|
||||
class="r-v2-shell__row"
|
||||
:style="rowGridStyle"
|
||||
>
|
||||
<template
|
||||
v-for="(p, slotIdx) in rowPositions(asRow(item as GalleryItem))"
|
||||
@@ -802,6 +828,7 @@ defineExpose({
|
||||
:show-platform-badge="showPlatformBadge"
|
||||
selectable
|
||||
:position="p"
|
||||
@ratio="onCardRatio"
|
||||
/>
|
||||
<GameCardSkeleton v-else />
|
||||
</template>
|
||||
@@ -831,7 +858,6 @@ defineExpose({
|
||||
<div
|
||||
v-else-if="itemKind(item as GalleryItem) === 'skeleton-row'"
|
||||
class="r-v2-shell__row"
|
||||
:style="rowGridStyle"
|
||||
>
|
||||
<GameCardSkeleton
|
||||
v-for="n in Math.max(1, columns)"
|
||||
@@ -988,9 +1014,18 @@ defineExpose({
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Flow-packed wrapping row: same-height, natural-width cards laid left to
|
||||
right. The JS packer (useGalleryVirtualItems) chose how many cards fit,
|
||||
so `nowrap` is safe — a brief over/under-fill during the load transient
|
||||
(before measured ratios settle) just leaves a ragged right edge or a
|
||||
hair of overflow, corrected on the next re-pack. `align-items:
|
||||
flex-start` keeps every card pinned to the same top. The 12px column gap
|
||||
matches the packer's `gap`; the 18px row gap matches the chrome math. */
|
||||
.r-v2-shell__row {
|
||||
display: grid;
|
||||
gap: 18px 12px;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
@@ -1086,14 +1121,12 @@ defineExpose({
|
||||
);
|
||||
}
|
||||
|
||||
/* Smaller default cards on phones so the grid packs 2–3 per row instead
|
||||
of one stretched card. The grid `minmax(--r-card-art-w, 1fr)` and the
|
||||
GameCards (default size, reading the token) both shrink in lock-step;
|
||||
the JS column-chunking above uses a matching 108px card width. Height
|
||||
is left to the card's `--r-cover-ratio` derive rule so the boxart style
|
||||
drives the cover shape on phones too. */
|
||||
/* Smaller cards on phones. Matches GameCard's own xs `--r-card-art-w`
|
||||
(130px) so the shell's skeletons and the flow-packer's card-height
|
||||
reference (`cardWidth()` in script) stay in lock-step with what the
|
||||
cards actually render. */
|
||||
html[data-bp~="xs"] .r-v2-shell {
|
||||
--r-card-art-w: 108px;
|
||||
--r-card-art-w: 130px;
|
||||
}
|
||||
|
||||
html[data-bp~="xs"] .r-v2-shell__scroller {
|
||||
|
||||
@@ -234,6 +234,10 @@ function onPlatformClick(e: MouseEvent) {
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", event: MouseEvent): void;
|
||||
/** The cover's natural aspect ratio (width / height) once its image
|
||||
* loads — forwarded from GameCover so the gallery can flow-pack cards
|
||||
* at their true shape. Carries the rom id so the consumer can key it. */
|
||||
(e: "ratio", payload: { romId: number; ratio: number }): void;
|
||||
}>();
|
||||
|
||||
// Gallery selection — only wired when the consumer opts in via
|
||||
@@ -409,6 +413,7 @@ function onStaticKeydown(e: KeyboardEvent) {
|
||||
:webp="webp"
|
||||
:active="coverActive"
|
||||
:morph-id="isSynthetic ? null : rom.id"
|
||||
@ratio="emit('ratio', { romId: rom.id, ratio: $event })"
|
||||
>
|
||||
<!-- Selection checkbox — top-left, drawn over the cover. Hidden
|
||||
at rest; appears on hover for discoverability, and stays
|
||||
@@ -524,13 +529,30 @@ function onStaticKeydown(e: KeyboardEvent) {
|
||||
color: var(--r-color-fg);
|
||||
}
|
||||
|
||||
/* Default (gallery) card derives its art height from the active cover
|
||||
ratio so the boxart style drives the shape (cover_path 2/3, box3d 3/4,
|
||||
physical / miximage 1/1). Explicit size tiers + hero keep their fixed
|
||||
footprints — they set `--r-card-art-h` directly. `--r-cover-ratio` is
|
||||
set inline by `useCoverArt`; the fallback keeps standalone cards sane. */
|
||||
/* Default (gallery) card: FIXED art height, NATURAL width. The height is
|
||||
the footprint a 2/3 box-art cover would have at `--r-card-art-w`, so
|
||||
box-art cards keep their familiar size; the cover's real width then
|
||||
follows its own aspect ratio (`--r-cover-ratio`, set per-image by
|
||||
GameCover). Cards end up the same height with varying widths. Explicit
|
||||
size tiers + hero keep their fixed footprints (set `--r-card-art-h`/
|
||||
width directly). */
|
||||
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) {
|
||||
--r-card-art-h: calc(var(--r-card-art-w) / var(--r-cover-ratio, 0.6667));
|
||||
--r-card-art-h: calc(var(--r-card-art-w) / 0.6667);
|
||||
width: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Width follows the cover (overrides GameCover's base `width: 100%`). */
|
||||
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) .r-gc__art {
|
||||
width: auto;
|
||||
}
|
||||
/* Keep the label from widening the card past its cover: it contributes 0
|
||||
to the card's intrinsic width but fills (and ellipsises within) whatever
|
||||
width the cover establishes. */
|
||||
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) .r-gc__label {
|
||||
width: 0;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* The art box IS the shared <GameCover> (this class lands on its root).
|
||||
|
||||
@@ -81,6 +81,13 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
morphStatic: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Fires with the rendered image's natural aspect ratio (width / height)
|
||||
* once it loads. Lets a surface that lays cards out by their true shape
|
||||
* (the gallery's wrapping rows) pack without a forced ratio. */
|
||||
ratio: [number];
|
||||
}>();
|
||||
|
||||
const art = useCoverArt(() => props.rom, {
|
||||
coverSrc: () => props.coverSrc,
|
||||
forceStyle: props.forceStyle
|
||||
@@ -115,12 +122,30 @@ const coverLoaded = ref(false);
|
||||
const activeSrc = computed(() =>
|
||||
showFallback.value ? art.fallbackUrl.value : art.coverUrl.value,
|
||||
);
|
||||
// Natural aspect ratio (width / height) of the rendered image, measured on
|
||||
// load. Drives the cover box's actual shape — no forced style ratio. Null
|
||||
// until the image decodes (or when showing the placeholder), where we fall
|
||||
// back to the style ratio as a sensible first guess.
|
||||
const naturalRatio = ref<number | null>(null);
|
||||
function measureNaturalRatio() {
|
||||
const el = imgEl.value;
|
||||
if (el && el.naturalWidth > 0 && el.naturalHeight > 0) {
|
||||
const r = el.naturalWidth / el.naturalHeight;
|
||||
naturalRatio.value = r;
|
||||
emit("ratio", r);
|
||||
}
|
||||
}
|
||||
watch(activeSrc, () => {
|
||||
coverLoaded.value = false;
|
||||
naturalRatio.value = null;
|
||||
});
|
||||
const onCoverLoad = () => {
|
||||
coverLoaded.value = true;
|
||||
measureNaturalRatio();
|
||||
};
|
||||
// The box shape: the image's true ratio once known, else the style ratio
|
||||
// as a first guess (keeps box-art cards from jumping — they're 2/3 anyway).
|
||||
const boxRatio = computed(() => naturalRatio.value ?? art.ratio.value);
|
||||
|
||||
const selfHover = ref(false);
|
||||
const coverActive = computed(
|
||||
@@ -161,6 +186,7 @@ onMounted(() => {
|
||||
// soft initial paint instead of getting stuck blurred).
|
||||
if (imgEl.value?.complete && imgEl.value.naturalWidth > 0) {
|
||||
coverLoaded.value = true;
|
||||
measureNaturalRatio();
|
||||
}
|
||||
if (!props.hoverMotion) return;
|
||||
rootEl.value?.addEventListener("mouseenter", onEnter);
|
||||
@@ -187,7 +213,7 @@ defineExpose({
|
||||
ref="rootEl"
|
||||
class="game-cover"
|
||||
:class="{ 'game-cover--alt': isAltStyle }"
|
||||
:style="[{ '--r-cover-ratio': art.ratio.value }, morphStyle]"
|
||||
:style="[{ '--r-cover-ratio': boxRatio }, morphStyle]"
|
||||
>
|
||||
<img
|
||||
v-if="showingImage"
|
||||
|
||||
@@ -68,6 +68,42 @@ export function galleryRowHeight(
|
||||
return Math.round(cardWidth / ratio) + ROW_CHROME_PX;
|
||||
}
|
||||
|
||||
/** Flow-pack a contiguous run of positions into rows that each fill the
|
||||
* available width: cards keep their natural width (`cardHeight * ratio`)
|
||||
* and wrap to the next row when the next card would overflow. A card wider
|
||||
* than the whole row still gets its own row (never an empty row). Pure —
|
||||
* exported for unit tests. `ratioAt` must already apply any default. */
|
||||
export function packFlowRows(
|
||||
start: number,
|
||||
end: number,
|
||||
rowWidth: number,
|
||||
cardHeight: number,
|
||||
gap: number,
|
||||
ratioAt: (position: number) => number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const rows: Array<{ start: number; end: number }> = [];
|
||||
if (end <= start) return rows;
|
||||
let rowStart = start;
|
||||
let acc = 0;
|
||||
for (let p = start; p < end; p++) {
|
||||
const w = cardHeight * ratioAt(p);
|
||||
if (p === rowStart) {
|
||||
acc = w;
|
||||
continue;
|
||||
}
|
||||
const next = acc + gap + w;
|
||||
if (next > rowWidth) {
|
||||
rows.push({ start: rowStart, end: p });
|
||||
rowStart = p;
|
||||
acc = w;
|
||||
} else {
|
||||
acc = next;
|
||||
}
|
||||
}
|
||||
rows.push({ start: rowStart, end });
|
||||
return rows;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
layout: Ref<LayoutMode> | ComputedRef<LayoutMode>;
|
||||
groupBy: Ref<GroupByMode> | ComputedRef<GroupByMode>;
|
||||
@@ -89,13 +125,22 @@ interface Options {
|
||||
notFoundMessage?: Ref<string> | ComputedRef<string>;
|
||||
/** Skeleton row count while loading the first window. */
|
||||
skeletonRowCount?: number;
|
||||
/** Active cover aspect ratio (width / height) — drives grid row height
|
||||
* so AlphaStrip jumps and scroll offsets stay exact when the boxart
|
||||
* style changes the cover shape. Defaults to box-art 2/3. */
|
||||
coverRatio?: MaybeRefOrGetter<number>;
|
||||
/** Card-art width used to derive the row height (match the value fed to
|
||||
* `useResponsiveColumns`). Defaults to the md card (158px). */
|
||||
cardWidth?: MaybeRefOrGetter<number>;
|
||||
/** Fixed card-art HEIGHT in px — every card shares it, so every grid row
|
||||
* has the same height (the scroller stays exact-offset). Defaults to the
|
||||
* md footprint (a 2/3 cover at 158px → 237px). */
|
||||
cardHeight?: MaybeRefOrGetter<number>;
|
||||
/** Usable px width of a row (container minus gutters / AlphaStrip). The
|
||||
* flow-packer fills a row until the next card would overflow it. */
|
||||
rowWidth?: MaybeRefOrGetter<number>;
|
||||
/** Horizontal gap between cards in px (default 12). */
|
||||
gap?: MaybeRefOrGetter<number>;
|
||||
/** Natural cover ratio (width / height) for a position — the card's
|
||||
* width is `cardHeight * ratioAt(p)`. Defaults to box-art 2/3 for
|
||||
* positions whose image hasn't been measured yet. */
|
||||
ratioAt?: (position: number) => number;
|
||||
/** Bump to force a re-pack when measured ratios change. Read inside the
|
||||
* layout so Vue tracks it; the packer itself calls `ratioAt`. */
|
||||
ratioVersion?: Ref<number> | ComputedRef<number>;
|
||||
}
|
||||
|
||||
const ALPHABET = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ@".split("");
|
||||
@@ -156,21 +201,24 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
buildLetterRanges(opts.charIndex.value, opts.total.value),
|
||||
);
|
||||
|
||||
// Per-style row height — recomputes when the boxart ratio or the
|
||||
// responsive card width changes, so every grid row keeps an exact
|
||||
// offset (the scroller is exact-offset, not measured).
|
||||
const rowHeightPx = computed(() =>
|
||||
galleryRowHeight(
|
||||
opts.coverRatio != null ? toValue(opts.coverRatio) : DEFAULT_COVER_RATIO,
|
||||
opts.cardWidth != null
|
||||
? toValue(opts.cardWidth)
|
||||
: REFERENCE_COVER_WIDTH_PX,
|
||||
),
|
||||
);
|
||||
const cardHeightPx = () =>
|
||||
opts.cardHeight != null
|
||||
? toValue(opts.cardHeight)
|
||||
: Math.round(REFERENCE_COVER_WIDTH_PX / DEFAULT_COVER_RATIO);
|
||||
|
||||
// Uniform row height: every card shares one fixed art height, so every
|
||||
// grid row is the same height regardless of how many (variable-width)
|
||||
// cards it holds. Keeps the scroller exact-offset with no measuring.
|
||||
const rowHeightPx = computed(() => cardHeightPx() + ROW_CHROME_PX);
|
||||
|
||||
const ratioAt = (position: number): number => {
|
||||
const r = opts.ratioAt?.(position);
|
||||
return r != null && r > 0 ? r : DEFAULT_COVER_RATIO;
|
||||
};
|
||||
|
||||
// Signature matches RVirtualScroller's `getItemHeight` prop (which uses
|
||||
// `unknown` because the primitive is generic). Row / skeleton-row use
|
||||
// the per-style height; every other kind is fixed.
|
||||
// `unknown` because the primitive is generic). Row / skeleton-row share
|
||||
// the uniform height; every other kind is fixed.
|
||||
function getItemHeight(item: unknown): number {
|
||||
const { kind } = item as GalleryItem;
|
||||
if (kind === "row" || kind === "skeleton-row") return rowHeightPx.value;
|
||||
@@ -259,47 +307,64 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
return items;
|
||||
}
|
||||
|
||||
const cols = Math.max(1, opts.columns.value);
|
||||
const total = opts.total.value;
|
||||
const ranges = letterRanges.value;
|
||||
const cardHeight = cardHeightPx();
|
||||
const gap = opts.gap != null ? toValue(opts.gap) : 12;
|
||||
// Fall back to a one-card-wide row before the container is measured, so
|
||||
// packing degrades to one-per-row rather than dividing by zero.
|
||||
const rowWidth = Math.max(
|
||||
cardHeight,
|
||||
opts.rowWidth != null ? toValue(opts.rowWidth) : 0,
|
||||
);
|
||||
// Read the version so a measured-ratio change re-runs the pack.
|
||||
void (opts.ratioVersion ? opts.ratioVersion.value : 0);
|
||||
|
||||
if (opts.groupBy.value === "letter") {
|
||||
// Group by letter — each letter section gets a header followed by
|
||||
// its own row chunks (rows always restart at the letter's first
|
||||
// position, so a letter never shares a visual row with another).
|
||||
// Group by letter — each letter section gets a header followed by its
|
||||
// own flow-packed rows (rows restart at the letter's first position,
|
||||
// so a letter never shares a visual row with another).
|
||||
for (const range of ranges) {
|
||||
items.push({
|
||||
kind: "letter-header",
|
||||
key: `lh-${range.letter}`,
|
||||
letter: range.letter,
|
||||
});
|
||||
const rowsInGroup = Math.ceil((range.end - range.start) / cols);
|
||||
for (let r = 0; r < rowsInGroup; r++) {
|
||||
const rowStart = range.start + r * cols;
|
||||
const rowEnd = Math.min(rowStart + cols, range.end);
|
||||
for (const row of packFlowRows(
|
||||
range.start,
|
||||
range.end,
|
||||
rowWidth,
|
||||
cardHeight,
|
||||
gap,
|
||||
ratioAt,
|
||||
)) {
|
||||
items.push({
|
||||
kind: "row",
|
||||
key: `row-${range.letter}-${r}`,
|
||||
rowIndex: r,
|
||||
startPosition: rowStart,
|
||||
endPosition: rowEnd,
|
||||
key: `row-${row.start}`,
|
||||
rowIndex: 0,
|
||||
startPosition: row.start,
|
||||
endPosition: row.end,
|
||||
letters: [range.letter],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Flat — rows are aligned to absolute positions.
|
||||
const totalRows = Math.ceil(total / cols);
|
||||
for (let r = 0; r < totalRows; r++) {
|
||||
const rowStart = r * cols;
|
||||
const rowEnd = Math.min(rowStart + cols, total);
|
||||
// Flat — flow-pack the whole list; rows stay contiguous position runs.
|
||||
for (const row of packFlowRows(
|
||||
0,
|
||||
total,
|
||||
rowWidth,
|
||||
cardHeight,
|
||||
gap,
|
||||
ratioAt,
|
||||
)) {
|
||||
items.push({
|
||||
kind: "row",
|
||||
key: `row-flat-${r}`,
|
||||
rowIndex: r,
|
||||
startPosition: rowStart,
|
||||
endPosition: rowEnd,
|
||||
letters: lettersInRange(ranges, rowStart, rowEnd),
|
||||
key: `row-${row.start}`,
|
||||
rowIndex: 0,
|
||||
startPosition: row.start,
|
||||
endPosition: row.end,
|
||||
letters: lettersInRange(ranges, row.start, row.end),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -343,20 +408,22 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
}
|
||||
|
||||
if (opts.groupBy.value !== "letter") {
|
||||
// Flat mode — for each letter range, jump to the row that holds
|
||||
// its first position.
|
||||
let firstRowIdx = -1;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "row") {
|
||||
firstRowIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstRowIdx >= 0) {
|
||||
const cols = Math.max(1, opts.columns.value);
|
||||
for (const range of letterRanges.value) {
|
||||
if (map.has(range.letter)) continue;
|
||||
map.set(range.letter, firstRowIdx + Math.floor(range.start / cols));
|
||||
// Flat mode — rows are variable-size contiguous runs, so map each
|
||||
// letter to the row whose position range contains its first position.
|
||||
// Rows and letterRanges are both ascending, so one forward walk with
|
||||
// a letter pointer assigns every letter in O(rows + letters).
|
||||
const ranges = letterRanges.value; // sorted by start
|
||||
let li = 0;
|
||||
for (let i = 0; i < items.length && li < ranges.length; i++) {
|
||||
const it = items[i];
|
||||
if (it.kind !== "row") continue;
|
||||
while (
|
||||
li < ranges.length &&
|
||||
ranges[li].start >= it.startPosition &&
|
||||
ranges[li].start < it.endPosition
|
||||
) {
|
||||
if (!map.has(ranges[li].letter)) map.set(ranges[li].letter, i);
|
||||
li++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { packFlowRows } from "./index";
|
||||
|
||||
// Card width = cardHeight * ratio. With cardHeight 100 and ratio 1 each
|
||||
// card is 100px wide; a 12px gap sits between cards in a row.
|
||||
const SQUARE = () => 1; // 100px wide per card
|
||||
const H = 100;
|
||||
const GAP = 12;
|
||||
|
||||
describe("packFlowRows", () => {
|
||||
it("fills a row until the next card would overflow, then wraps", () => {
|
||||
// Row width 340: card(100) +12+100 +12+100 = 324 fits; a 4th (+12+100)
|
||||
// = 436 > 340 → wraps. So 3 per row.
|
||||
const rows = packFlowRows(0, 7, 340, H, GAP, SQUARE);
|
||||
expect(rows).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 3, end: 6 },
|
||||
{ start: 6, end: 7 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("packs more, narrower cards per row when covers are portrait", () => {
|
||||
// Portrait 0.5 → 50px wide. Row 340: 50,+12+50(112),+62(174),+62(236),
|
||||
// +62(298),+62(360>340 stop) → 5 per row.
|
||||
const rows = packFlowRows(0, 5, 340, H, GAP, () => 0.5);
|
||||
expect(rows).toEqual([{ start: 0, end: 5 }]);
|
||||
});
|
||||
|
||||
it("gives an over-wide card its own row instead of an empty one", () => {
|
||||
// ratio 4 → 400px wide > row 340. Each lands alone.
|
||||
const rows = packFlowRows(0, 2, 340, H, GAP, () => 4);
|
||||
expect(rows).toEqual([
|
||||
{ start: 0, end: 1 },
|
||||
{ start: 1, end: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("mixes widths within a row by running natural width", () => {
|
||||
const ratioAt = (p: number) => (p === 1 ? 2 : 0.5); // 50,200,50,50
|
||||
// Row 340: p0=50; +12+200=262; +12+50=324; +12+50=386>340 → wrap.
|
||||
const rows = packFlowRows(0, 4, 340, H, GAP, ratioAt);
|
||||
expect(rows).toEqual([
|
||||
{ start: 0, end: 3 },
|
||||
{ start: 3, end: 4 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty range", () => {
|
||||
expect(packFlowRows(5, 5, 340, H, GAP, SQUARE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,10 @@ export function useResponsiveColumns(
|
||||
const min = options.min ?? 1;
|
||||
|
||||
const columns = ref<number>(min);
|
||||
// Observed content width minus `inset` — the px available to a row of
|
||||
// cards. Exposed for consumers that flow-pack by width (the gallery's
|
||||
// wrapping rows) rather than chunk by a fixed column count.
|
||||
const usableWidth = ref<number>(0);
|
||||
let observer: ResizeObserver | null = null;
|
||||
// Last observed width — kept so a change in a reactive option (card
|
||||
// width / inset flipping at a breakpoint) can recompute without waiting
|
||||
@@ -58,6 +62,7 @@ export function useResponsiveColumns(
|
||||
const inset = resolve(options.inset, 0);
|
||||
const usable = width - inset;
|
||||
if (usable <= 0) return;
|
||||
if (usable !== usableWidth.value) usableWidth.value = usable;
|
||||
// CSS auto-fill semantics: floor((containerWidth + gap) / (cardWidth + gap))
|
||||
const next = Math.max(min, Math.floor((usable + gap) / (cardWidth + gap)));
|
||||
if (next !== columns.value) columns.value = next;
|
||||
@@ -101,5 +106,5 @@ export function useResponsiveColumns(
|
||||
|
||||
onBeforeUnmount(detach);
|
||||
|
||||
return { columns };
|
||||
return { columns, usableWidth };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user