mirror of
https://github.com/rommapp/romm.git
synced 2026-06-29 15:25:46 +00:00
Merge pull request #3576 from rommapp/chore/improve-gallery-responsiveness-z
feat(v2): improve gallery responsiveness
This commit is contained in:
@@ -395,9 +395,25 @@ function onViewportRangeChange(range: { first: number; last: number }) {
|
||||
scheduleFetchSync(range);
|
||||
}
|
||||
|
||||
// Overscan kept on each side of the viewport — single source for both the
|
||||
// RVirtualScroller prop and the debug-overlay window math below.
|
||||
const VIRTUAL_OVERSCAN = 25;
|
||||
// Rows kept rendered beyond the viewport. Adaptive so the rendered CARD count
|
||||
// stays bounded regardless of how many columns fit: a fixed row overscan on a
|
||||
// wide screen (~9 cards/row) mounts hundreds of off-screen cards, and each
|
||||
// card is an expensive subtree (cover + hover overlay + shared game actions).
|
||||
// We target a roughly constant overscan-card budget per side, clamped so a
|
||||
// single-column phone keeps enough rows for smooth scrolling without
|
||||
// overshooting. List rows are one item each → a flat row count is fine.
|
||||
const GRID_OVERSCAN_CARDS = 60;
|
||||
const virtualOverscan = computed(() =>
|
||||
layout.value === "list"
|
||||
? 12
|
||||
: Math.min(
|
||||
20,
|
||||
Math.max(
|
||||
4,
|
||||
Math.round(GRID_OVERSCAN_CARDS / Math.max(1, columns.value)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// ── Debug overlay bridge ────────────────────────────────────────────
|
||||
// Publish the virtual scroller's window stats so the global DebugOverlay
|
||||
@@ -416,8 +432,9 @@ watchEffect(() => {
|
||||
const total = items.length;
|
||||
const vr = viewportRange.value;
|
||||
const empty = total === 0 || vr.last < vr.first;
|
||||
const first = empty ? 0 : Math.max(0, vr.first - VIRTUAL_OVERSCAN);
|
||||
const last = empty ? -1 : Math.min(total - 1, vr.last + VIRTUAL_OVERSCAN);
|
||||
const overscan = virtualOverscan.value;
|
||||
const first = empty ? 0 : Math.max(0, vr.first - overscan);
|
||||
const last = empty ? -1 : Math.min(total - 1, vr.last + overscan);
|
||||
// Count the cards actually mounted in the rendered window — grid rows fan
|
||||
// out into many cards, so this is the real DOM weight (not just row count).
|
||||
let renderedCards = 0;
|
||||
@@ -436,7 +453,7 @@ watchEffect(() => {
|
||||
renderedCards,
|
||||
viewportFirst: vr.first,
|
||||
viewportLast: vr.last,
|
||||
overscan: VIRTUAL_OVERSCAN,
|
||||
overscan,
|
||||
scrollTop: scrollerRef.value?.scrollTop ?? 0,
|
||||
});
|
||||
});
|
||||
@@ -743,6 +760,16 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem;
|
||||
const asListRow = (i: GalleryItem) => i as ListRowItem;
|
||||
const itemKind = (i: GalleryItem) => i.kind;
|
||||
|
||||
// Stable identity for the virtualiser. Each GalleryItem carries a content-
|
||||
// derived `key` (`row-${start}`, `lh-${letter}`, `lr-${p}`, …); feeding it to
|
||||
// RVirtualScroller as `get-item-key` lets Vue MATCH rows across a re-pack and
|
||||
// MOVE the unchanged ones instead of patching by array index. Index-keying
|
||||
// (the default) reassigns every slot's content when a re-pack inserts/removes
|
||||
// a row above the viewport, remounting every visible card (image re-decode +
|
||||
// reveal). With identity keys, a moved row keeps its DOM and its inner cards
|
||||
// (keyed by `:key="p"`) patch in place. `unknown` matches the prop signature.
|
||||
const galleryItemKey = (item: unknown): string => (item as GalleryItem).key;
|
||||
|
||||
// View-facing surface. Methods only — internal state stays internal.
|
||||
defineExpose({
|
||||
/** Re-apply the previously-saved scroll position for the current route
|
||||
@@ -773,7 +800,8 @@ defineExpose({
|
||||
ref="scrollerRef"
|
||||
:items="virtualItems"
|
||||
:get-item-height="getItemHeight"
|
||||
:overscan="VIRTUAL_OVERSCAN"
|
||||
:get-item-key="galleryItemKey"
|
||||
:overscan="virtualOverscan"
|
||||
class="r-v2-shell__scroller r-v2-scroll-hidden"
|
||||
@update:viewport-range="onViewportRangeChange"
|
||||
>
|
||||
|
||||
@@ -43,7 +43,10 @@ import type { SimpleRom } from "@/stores/roms";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import { romStatusMap } from "@/utils";
|
||||
import GameActionsList from "@/v2/components/GameActions/GameActionsList.vue";
|
||||
import { useGameActions } from "@/v2/composables/useGameActions";
|
||||
import {
|
||||
GAME_ACTIONS_KEY,
|
||||
useGameActions,
|
||||
} from "@/v2/composables/useGameActions";
|
||||
import {
|
||||
ENUM_KEYS,
|
||||
FLAG_KEYS,
|
||||
@@ -101,7 +104,14 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
});
|
||||
|
||||
const romRef = toRef(props, "rom");
|
||||
const actions = useGameActions(() => romRef.value);
|
||||
// Reuse the host's shared instance when one is provided (GameCard provides a
|
||||
// single `useGameActions` for all its buttons — see GAME_ACTIONS_KEY). Falls
|
||||
// back to an own instance for standalone use (GameDetails header, list rows).
|
||||
// Safe to call conditionally: useGameActions registers no lifecycle hooks, and
|
||||
// setup decides the branch once at mount. The provided instance is bound to
|
||||
// the same rom this button receives, so behaviour is identical.
|
||||
const sharedActions = inject(GAME_ACTIONS_KEY, null);
|
||||
const actions = sharedActions ?? useGameActions(() => romRef.value);
|
||||
|
||||
const enumStatus = computed<RomUserStatus | null>(
|
||||
() => props.rom.rom_user?.status ?? null,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
// * `#overlay` slot renders content on top of the cover for badges
|
||||
// that aren't part of the default gallery overlay (e.g. metadata
|
||||
// provider logos in the match-flow source picker).
|
||||
import { computed, ref } from "vue";
|
||||
import { computed, provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { SimpleRom } from "@/stores/roms";
|
||||
@@ -48,7 +48,10 @@ import GameCover from "@/v2/components/shared/GameCover.vue";
|
||||
import { useBackgroundArt } from "@/v2/composables/useBackgroundArt";
|
||||
import { useCoverArt } from "@/v2/composables/useCoverArt";
|
||||
import { useGallerySelectionInput } from "@/v2/composables/useGallerySelectionInput";
|
||||
import { useGameActions } from "@/v2/composables/useGameActions";
|
||||
import {
|
||||
GAME_ACTIONS_KEY,
|
||||
useGameActions,
|
||||
} from "@/v2/composables/useGameActions";
|
||||
import { useViewTransition } from "@/v2/composables/useViewTransition";
|
||||
import RCheckbox from "@/v2/lib/forms/RCheckbox/RCheckbox.vue";
|
||||
import RPlatformIcon from "@/v2/lib/media/RPlatformIcon/RPlatformIcon.vue";
|
||||
@@ -222,14 +225,23 @@ const ratingLabel = computed(() => {
|
||||
// the regular router-link behaviour so opening in a new tab still works.
|
||||
const router = useRouter();
|
||||
const { morphTransition } = useViewTransition();
|
||||
const actions = useGameActions(() => props.rom);
|
||||
// Only interactive cards need actions: static / decorative cards (list-row
|
||||
// thumbnails, dialog tiles) render no overlay, so creating the composable for
|
||||
// them is pure waste. When present, share this single instance with every
|
||||
// GameActionBtn in the overlay (play / download / collection / favorite /
|
||||
// status / more) instead of each button spinning up its own — the difference
|
||||
// between ~1 and ~7 live `useGameActions` per card, which dominates a
|
||||
// virtualised grid's cost. Safe to call conditionally (no lifecycle hooks).
|
||||
const actions =
|
||||
props.static || props.decorative ? null : useGameActions(() => props.rom);
|
||||
if (actions) provide(GAME_ACTIONS_KEY, actions);
|
||||
|
||||
// Stop propagation so the card's morph + router push doesn't fire when
|
||||
// the user actually wanted to jump to the platform gallery.
|
||||
function onPlatformClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
actions.goToPlatform();
|
||||
actions?.goToPlatform();
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -479,7 +491,7 @@ function onStaticKeydown(e: KeyboardEvent) {
|
||||
<div class="r-gc__overlay">
|
||||
<div class="r-gc__overlay-center">
|
||||
<GameActionBtn
|
||||
v-if="actions.canPlay.value"
|
||||
v-if="actions?.canPlay.value"
|
||||
:rom="rom"
|
||||
action="play"
|
||||
variant="emphasized"
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
// `--r-cover-radius` var (defaults to the gallery card radius).
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import CoverPlaceholder from "@/v2/components/shared/CoverPlaceholder.vue";
|
||||
import { revealedCoverSrcs } from "@/v2/components/shared/coverReveal";
|
||||
import { useCoverAnimation } from "@/v2/composables/useCoverAnimation";
|
||||
import {
|
||||
useCoverArt,
|
||||
@@ -132,12 +133,15 @@ function measureNaturalRatio() {
|
||||
emit("ratio", r);
|
||||
}
|
||||
}
|
||||
watch(activeSrc, () => {
|
||||
coverLoaded.value = false;
|
||||
watch(activeSrc, (src) => {
|
||||
// Seed from the session-seen set: a URL we've already bloomed once skips the
|
||||
// reveal so a recycled card doesn't re-flash it (and doesn't pay the blur).
|
||||
coverLoaded.value = !!src && revealedCoverSrcs.has(src);
|
||||
naturalRatio.value = null;
|
||||
});
|
||||
const onCoverLoad = () => {
|
||||
coverLoaded.value = true;
|
||||
if (activeSrc.value) revealedCoverSrcs.add(activeSrc.value);
|
||||
measureNaturalRatio();
|
||||
};
|
||||
// True ratio once known, else the style ratio as a first guess.
|
||||
@@ -177,11 +181,17 @@ const onLeave = () => {
|
||||
selfHover.value = false;
|
||||
};
|
||||
onMounted(() => {
|
||||
// Already bloomed this URL once this session → skip the reveal on this
|
||||
// (recycled) mount, regardless of whether the <img> reports `complete` yet.
|
||||
if (activeSrc.value && revealedCoverSrcs.has(activeSrc.value)) {
|
||||
coverLoaded.value = true;
|
||||
}
|
||||
// A cached cover can already be decoded before the load listener binds —
|
||||
// mark it loaded so the reveal still resolves (it bloom-snaps from the
|
||||
// soft initial paint instead of getting stuck blurred).
|
||||
if (imgEl.value?.complete && imgEl.value.naturalWidth > 0) {
|
||||
coverLoaded.value = true;
|
||||
if (activeSrc.value) revealedCoverSrcs.add(activeSrc.value);
|
||||
measureNaturalRatio();
|
||||
}
|
||||
if (!props.hoverMotion) return;
|
||||
|
||||
10
frontend/src/v2/components/shared/coverReveal.ts
Normal file
10
frontend/src/v2/components/shared/coverReveal.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Session-shared set of cover URLs that have finished loading (bloomed) at
|
||||
// least once. GameCover reads it so a recycled card (virtual scroll) pointing
|
||||
// at an already-seen URL skips the blur-up reveal: replaying the
|
||||
// `filter: blur(16px)` bloom on every remount is just flicker, and during the
|
||||
// gallery's re-packs — which remount cards as positions shuffle between rows —
|
||||
// that per-card blur cost compounds into real jank.
|
||||
//
|
||||
// Module-level so the state is shared across every GameCover instance. Bounded
|
||||
// by the count of unique covers seen this session; reset on reload.
|
||||
export const revealedCoverSrcs = new Set<string>();
|
||||
@@ -19,7 +19,13 @@ import storeGalleryRoms from "@/v2/stores/galleryRoms";
|
||||
// Below this delta a new measurement isn't worth a re-pack (sub-pixel noise).
|
||||
const RATIO_EPSILON = 0.01;
|
||||
// Wait this long after the last new ratio before bumping `ratioVersion`.
|
||||
const REPACK_DEBOUNCE_MS = 150;
|
||||
// Each bump re-packs the whole list (O(total)) and cascades into the
|
||||
// scroller's offsets + letterToIndex, so during a fast scroll through a large
|
||||
// library — where covers stream in continuously — a tighter window turns into
|
||||
// a re-pack storm that saturates the main thread. Coalesce more aggressively:
|
||||
// the layout settling to natural ratios a fraction of a second later is
|
||||
// imperceptible next to the jank a per-150ms full re-pack causes.
|
||||
const REPACK_DEBOUNCE_MS = 350;
|
||||
|
||||
export function useGalleryCoverRatios() {
|
||||
const galleryRoms = storeGalleryRoms();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assignFlatRowLetters } from "./index";
|
||||
|
||||
// Reference (the pre-restructure per-row scan) — each row collects every range
|
||||
// it overlaps. The linear `assignFlatRowLetters` must match this exactly.
|
||||
function naive(
|
||||
rows: ReadonlyArray<{ start: number; end: number }>,
|
||||
ranges: ReadonlyArray<{ letter: string; start: number; end: number }>,
|
||||
): string[][] {
|
||||
return rows.map((row) =>
|
||||
ranges
|
||||
.filter((r) => r.end > row.start && r.start < row.end)
|
||||
.map((r) => r.letter),
|
||||
);
|
||||
}
|
||||
|
||||
describe("assignFlatRowLetters", () => {
|
||||
// Letters partition the positions: A[0,4) B[4,9) C[9,12).
|
||||
const ranges = [
|
||||
{ letter: "A", start: 0, end: 4 },
|
||||
{ letter: "B", start: 4, end: 9 },
|
||||
{ letter: "C", start: 9, end: 12 },
|
||||
];
|
||||
|
||||
it("tags a row fully inside one letter with just that letter", () => {
|
||||
expect(assignFlatRowLetters([{ start: 0, end: 3 }], ranges)).toEqual([
|
||||
["A"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("tags a row straddling a boundary with both letters", () => {
|
||||
// [2,6) overlaps A[0,4) and B[4,9).
|
||||
expect(assignFlatRowLetters([{ start: 2, end: 6 }], ranges)).toEqual([
|
||||
["A", "B"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("tags a row spanning three letters with all three", () => {
|
||||
expect(assignFlatRowLetters([{ start: 3, end: 12 }], ranges)).toEqual([
|
||||
["A", "B", "C"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("advances correctly across many consecutive rows", () => {
|
||||
const rows = [
|
||||
{ start: 0, end: 3 }, // A
|
||||
{ start: 3, end: 5 }, // A,B
|
||||
{ start: 5, end: 9 }, // B
|
||||
{ start: 9, end: 12 }, // C
|
||||
];
|
||||
expect(assignFlatRowLetters(rows, ranges)).toEqual([
|
||||
["A"],
|
||||
["A", "B"],
|
||||
["B"],
|
||||
["C"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches the naive per-row scan on a denser layout", () => {
|
||||
const rows = [
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 4 },
|
||||
{ start: 4, end: 4 }, // empty row (degenerate) → no letters
|
||||
{ start: 4, end: 11 },
|
||||
{ start: 11, end: 12 },
|
||||
];
|
||||
expect(assignFlatRowLetters(rows, ranges)).toEqual(naive(rows, ranges));
|
||||
});
|
||||
|
||||
it("returns empties when there are no ranges", () => {
|
||||
expect(assignFlatRowLetters([{ start: 0, end: 3 }], [])).toEqual([[]]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,16 @@
|
||||
// lands 72 ROMs only re-renders up to ⌈72/cols⌉ rows, not the entire
|
||||
// virtualItems array.
|
||||
//
|
||||
// Re-pack handling: a measured cover ratio change bumps `ratioVersion`, which
|
||||
// re-runs the build. Two things keep that cheap so a fast scroll through a
|
||||
// huge library doesn't thrash:
|
||||
// * Structural sharing — unchanged item objects are reused from the previous
|
||||
// build (keyed by their stable `key`), so a bump only allocates the rows
|
||||
// that actually reflowed (paired with the scroller's `get-item-key`, Vue
|
||||
// then moves the rest instead of remounting their cards).
|
||||
// * Linear letter assignment — flat rows get their letters in one merge pass
|
||||
// over the sorted ranges (`assignFlatRowLetters`), not a per-row scan.
|
||||
//
|
||||
// AlphaStrip integration is index-based: `letterToIndex` maps each
|
||||
// available letter to the index of its first item in `virtualItems`,
|
||||
// fed straight into `RVirtualScroller.scrollToIndex(idx, { stickyOffset })`.
|
||||
@@ -176,14 +186,25 @@ function buildLetterRanges(
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function lettersInRange(
|
||||
ranges: LetterRange[],
|
||||
startPos: number,
|
||||
endPos: number,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (const r of ranges) {
|
||||
if (r.end > startPos && r.start < endPos) out.push(r.letter);
|
||||
/** Assign each flat-packed row the letters its position range overlaps, in a
|
||||
* single linear pass. Rows and ranges are both ascending, so one advancing
|
||||
* `base` pointer over the ranges replaces the old per-row overlap scan
|
||||
* (O(rows × letters) → O(rows + letters)). Exported for unit tests. */
|
||||
export function assignFlatRowLetters(
|
||||
rows: ReadonlyArray<{ start: number; end: number }>,
|
||||
ranges: ReadonlyArray<LetterRange>,
|
||||
): string[][] {
|
||||
const out: string[][] = [];
|
||||
// `base` only advances: once a range ends at or before a row's start it can
|
||||
// never overlap a later (higher) row, so it's skipped for good.
|
||||
let base = 0;
|
||||
for (const row of rows) {
|
||||
while (base < ranges.length && ranges[base].end <= row.start) base++;
|
||||
const letters: string[] = [];
|
||||
for (let j = base; j < ranges.length && ranges[j].start < row.end; j++) {
|
||||
if (ranges[j].end > row.start) letters.push(ranges[j].letter);
|
||||
}
|
||||
out.push(letters);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -217,7 +238,64 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
return FIXED_HEIGHT_BY_KIND[kind] ?? 0;
|
||||
}
|
||||
|
||||
const virtualItems = computed<GalleryItem[]>(() => {
|
||||
// ── Structural sharing across re-packs ────────────────────────────────
|
||||
// A measured cover ratio change bumps `ratioVersion`, re-running the build.
|
||||
// Only the run of rows from the first re-measured cover onward actually
|
||||
// reflows; everything before stays identical. So we REUSE the previous
|
||||
// build's item objects whenever their content matches (looked up by stable
|
||||
// key). A reused object keeps its reference, so — paired with the scroller's
|
||||
// content-derived `get-item-key` — Vue moves the untouched rows instead of
|
||||
// re-rendering them, and only the reflowed rows allocate. This turns a
|
||||
// per-bump O(total) re-allocation into O(reflowed rows). `prevByKey` holds
|
||||
// the last build and survives across evaluations as closure state; if it
|
||||
// ever goes stale the only cost is a missed reuse (emitted items are always
|
||||
// correct, since they're built fresh from the current pack every time).
|
||||
let prevByKey = new Map<string, GalleryItem>();
|
||||
|
||||
function sameLetters(a: readonly string[], b: readonly string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
function shareRow(
|
||||
start: number,
|
||||
end: number,
|
||||
letters: string[],
|
||||
): GalleryItem {
|
||||
const prev = prevByKey.get(`row-${start}`);
|
||||
if (
|
||||
prev?.kind === "row" &&
|
||||
prev.endPosition === end &&
|
||||
sameLetters(prev.letters, letters)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
kind: "row",
|
||||
key: `row-${start}`,
|
||||
startPosition: start,
|
||||
endPosition: end,
|
||||
letters,
|
||||
};
|
||||
}
|
||||
function shareHeader(letter: string): GalleryItem {
|
||||
// A header's whole content is its letter, which the key encodes — same key
|
||||
// ⇒ identical, always reusable.
|
||||
const prev = prevByKey.get(`lh-${letter}`);
|
||||
if (prev?.kind === "letter-header") return prev;
|
||||
return { kind: "letter-header", key: `lh-${letter}`, letter };
|
||||
}
|
||||
function shareListRow(position: number, letter: string): GalleryItem {
|
||||
const prev = prevByKey.get(`lr-${position}`);
|
||||
if (prev?.kind === "list-row" && prev.letter === letter) return prev;
|
||||
return { kind: "list-row", key: `lr-${position}`, position, letter };
|
||||
}
|
||||
|
||||
// Build the structural body for the current state. Reads every layout dep
|
||||
// (so the wrapping computed tracks them) and routes row / header / list-row
|
||||
// emission through the share helpers above. Split out from the cache refresh
|
||||
// so every early-returning branch still flows through one place.
|
||||
function buildItems(): GalleryItem[] {
|
||||
const items: GalleryItem[] = [];
|
||||
|
||||
if (opts.notFound?.value) {
|
||||
@@ -230,14 +308,13 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
}
|
||||
|
||||
// List layout — one virtual item per ROM position. The view template
|
||||
// resolves each row via `getRomAt(position)` and renders skeleton vs
|
||||
// real inside the same kind. Group-by-letter in list mode is
|
||||
// deferred (see CLAUDE.md §X — list-mode MVP first).
|
||||
// resolves each row via `getRomAt(position)` and renders skeleton vs real
|
||||
// inside the same kind. Group-by-letter in list mode is deferred (see
|
||||
// CLAUDE.md §X — list-mode MVP first).
|
||||
if (opts.layout.value === "list") {
|
||||
if (opts.loadingInitial.value && opts.total.value === 0) {
|
||||
// Bootstrap phase — paint placeholder rows so the scroller has a
|
||||
// shape while metadata is in flight. Reasonable count to fill a
|
||||
// typical viewport without committing to a fixed number.
|
||||
// Bootstrap phase — placeholder rows give the scroller a shape while
|
||||
// metadata is in flight. Enough to fill a typical viewport.
|
||||
const skeletonListRows = Math.max(skeletonRows * 4, 12);
|
||||
for (let i = 0; i < skeletonListRows; i++) {
|
||||
items.push({
|
||||
@@ -258,10 +335,8 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
}
|
||||
const ranges = letterRanges.value;
|
||||
const total = opts.total.value;
|
||||
// Pre-compute a position → letter lookup so each list-row knows
|
||||
// which letter it belongs to (drives AlphaStrip's scroll-spy).
|
||||
// Walking `ranges` once (sorted by start) is O(total) — acceptable
|
||||
// because virtualItems already iterates `total`.
|
||||
// Walk `ranges` once (sorted by start) to tag each position with its
|
||||
// letter (drives AlphaStrip's scroll-spy).
|
||||
let rangeIdx = 0;
|
||||
for (let p = 0; p < total; p++) {
|
||||
while (
|
||||
@@ -270,19 +345,13 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
) {
|
||||
rangeIdx++;
|
||||
}
|
||||
const letter = ranges[rangeIdx]?.letter ?? "#";
|
||||
items.push({
|
||||
kind: "list-row",
|
||||
key: `lr-${p}`,
|
||||
position: p,
|
||||
letter,
|
||||
});
|
||||
items.push(shareListRow(p, ranges[rangeIdx]?.letter ?? "#"));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Grid + first-window-loading — show skeleton rows until the server
|
||||
// tells us `total` and `charIndex`.
|
||||
// Grid + first-window-loading — skeleton rows until the server returns
|
||||
// `total` and `charIndex`.
|
||||
if (opts.loadingInitial.value && opts.total.value === 0) {
|
||||
for (let i = 0; i < skeletonRows; i++) {
|
||||
items.push({ kind: "skeleton-row", key: `skel-${i}`, index: i });
|
||||
@@ -314,11 +383,7 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
if (opts.groupBy.value === "letter") {
|
||||
// Header + own flow-packed rows per letter (rows restart per letter).
|
||||
for (const range of ranges) {
|
||||
items.push({
|
||||
kind: "letter-header",
|
||||
key: `lh-${range.letter}`,
|
||||
letter: range.letter,
|
||||
});
|
||||
items.push(shareHeader(range.letter));
|
||||
for (const row of packFlowRows(
|
||||
range.start,
|
||||
range.end,
|
||||
@@ -327,35 +392,29 @@ export function useGalleryVirtualItems(opts: Options) {
|
||||
gap,
|
||||
ratioAt,
|
||||
)) {
|
||||
items.push({
|
||||
kind: "row",
|
||||
key: `row-${row.start}`,
|
||||
startPosition: row.start,
|
||||
endPosition: row.end,
|
||||
letters: [range.letter],
|
||||
});
|
||||
items.push(shareRow(row.start, row.end, [range.letter]));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 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-${row.start}`,
|
||||
startPosition: row.start,
|
||||
endPosition: row.end,
|
||||
letters: lettersInRange(ranges, row.start, row.end),
|
||||
});
|
||||
// Flat — flow-pack the whole list, then tag every row with its
|
||||
// overlapping letters in one linear pass (not a per-row scan).
|
||||
const rows = packFlowRows(0, total, rowWidth, cardHeight, gap, ratioAt);
|
||||
const lettersPerRow = assignFlatRowLetters(rows, ranges);
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
items.push(shareRow(rows[i].start, rows[i].end, lettersPerRow[i]));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const virtualItems = computed<GalleryItem[]>(() => {
|
||||
const items = buildItems();
|
||||
// Refresh the reuse cache from this build for the next re-pack — cheap
|
||||
// O(items) bookkeeping; the win is the avoided re-allocation in buildItems.
|
||||
const nextByKey = new Map<string, GalleryItem>();
|
||||
for (const it of items) nextByKey.set(it.key, it);
|
||||
prevByKey = nextByKey;
|
||||
return items;
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// actions.isFavorite // reactive Ref<boolean>
|
||||
// actions.canManageCollections // reactive Ref<boolean>
|
||||
import type { Emitter } from "mitt";
|
||||
import { computed, inject } from "vue";
|
||||
import { computed, inject, type InjectionKey } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { RomUserData, RomUserStatus } from "@/__generated__";
|
||||
@@ -341,3 +341,16 @@ export function useGameActions(getRom: () => SimpleRom | null | undefined) {
|
||||
removeFromContinuePlaying,
|
||||
};
|
||||
}
|
||||
|
||||
export type GameActions = ReturnType<typeof useGameActions>;
|
||||
|
||||
/** Injection key for sharing one `useGameActions` instance down a subtree.
|
||||
* A GameCard hosts ~6 GameActionBtn children (play / download / collection /
|
||||
* favorite / status / more); each one re-instantiating the full composable
|
||||
* (i18n + router + emitter + two stores + `useCan`×2 + favorite/can-play
|
||||
* computeds) is what made a virtualised grid of cards thousands of live
|
||||
* instances. The card creates a single instance and `provide`s it; each
|
||||
* button `inject`s and reuses it, falling back to its own only when used
|
||||
* standalone (GameDetails header, list rows) with no provider. */
|
||||
export const GAME_ACTIONS_KEY: InjectionKey<GameActions> =
|
||||
Symbol("v2:gameActions");
|
||||
|
||||
Reference in New Issue
Block a user