fix: screenshots page lagging with lots of images (#7336)

This commit is contained in:
Calum H.
2026-08-27 18:27:33 +00:00
committed by GitHub
parent d3b5ba2567
commit 263aaedc8f
2 changed files with 337 additions and 98 deletions
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
@@ -12,6 +12,9 @@ const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
@@ -43,6 +46,53 @@ const messages = defineMessages({
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
},
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onBeforeUnmount(() => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
@@ -98,44 +148,48 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<TransitionGroup
tag="div"
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
:enter-active-class="
animateEntry
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
: ''
"
:enter-from-class="animateEntry ? 'opacity-0' : ''"
enter-to-class="opacity-100 scale-100"
>
<ScreenshotCard
v-for="screenshot in screenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<TransitionGroup
tag="div"
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
:enter-active-class="
animateEntry
? 'transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none'
: ''
"
:enter-from-class="animateEntry ? 'opacity-0' : ''"
enter-to-class="opacity-100 scale-100"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-secondary opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</TransitionGroup>
</div>
</ScreenshotSection>
</div>
</template>
@@ -36,12 +36,13 @@ import {
ReadyTransition,
useFormatDateTime,
useReadyState,
useScrollViewport,
useVIntl,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { save } from '@tauri-apps/plugin-dialog'
import { readFile } from '@tauri-apps/plugin-fs'
import { useStorage } from '@vueuse/core'
import { useElementSize, useStorage, useWindowSize } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -107,6 +108,28 @@ type ScreenshotDropData = {
customGroupId?: string | null
}
type ScreenshotGroupLayout = {
group: ScreenshotGroupData
top: number
height: number
isOpen: boolean
gridHeight: number
gridTop: number
}
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
renderedScreenshots: InstanceScreenshot[]
virtualGridTop: number
}
const SCREENSHOT_GRID_GAP = 12
const SCREENSHOT_GROUP_SPACING = 12
const SCREENSHOT_GROUP_HEADER_HEIGHT = 40
const SCREENSHOT_GROUP_CONTENT_SPACING = 10
const SCREENSHOT_GROUP_OVERSCAN = 900
const SCREENSHOT_GRID_MIN_HEIGHT = 45
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
const props = withDefaults(
defineProps<{
instanceId?: string
@@ -151,6 +174,7 @@ const copiedScreenshotIds = ref(new Set<string>())
const copiedResetTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const screenshotsPage = ref<HTMLElement>()
const regrouping = ref(false)
const screenshotsScrolling = ref(false)
const screenshotToDelete = ref<InstanceScreenshot | null>(null)
const deleteFromPreview = ref(false)
const activeDrag = ref<ActiveScreenshotDrag | null>(null)
@@ -173,6 +197,25 @@ const route = useRoute()
const router = useRouter()
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
const {
listContainer: screenshotListContainer,
containerOffset: screenshotListOffset,
relativeScrollTop: screenshotListScrollTop,
scrollContainer: screenshotScrollContainer,
viewportHeight: screenshotViewportHeight,
} = useScrollViewport({
onScroll: () => {
screenshotsScrolling.value = true
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
screenshotsScrollIdleTimeout = setTimeout(() => {
screenshotsScrolling.value = false
screenshotsScrollIdleTimeout = undefined
}, 120)
},
})
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
const { width: windowWidth } = useWindowSize()
const formatDateTime = useFormatDateTime({ dateStyle: 'long', timeStyle: 'short' })
const formatMonth = useFormatDateTime({ month: 'long', year: 'numeric' })
const messages = defineMessages({
@@ -469,6 +512,93 @@ const groupedScreenshots = computed((): ScreenshotGroupData[] => {
})
})
const screenshotColumnCount = computed(() => {
if (windowWidth.value >= 1536) return 4
if (windowWidth.value >= 640) return 2
return 1
})
const screenshotCardWidth = computed(() => {
if (screenshotListWidth.value <= 0) return FALLBACK_SCREENSHOT_CARD_WIDTH
const gapsWidth = (screenshotColumnCount.value - 1) * SCREENSHOT_GRID_GAP
return Math.max(0, (screenshotListWidth.value - gapsWidth) / screenshotColumnCount.value)
})
const screenshotCardHeight = computed(() => (screenshotCardWidth.value * 9) / 16)
const screenshotRowHeight = computed(() => screenshotCardHeight.value + SCREENSHOT_GRID_GAP)
const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
const layouts: ScreenshotGroupLayout[] = []
let top = 0
for (const group of groupedScreenshots.value) {
const isHeaderHidden = groupBy.value === 'none'
const isOpen = isHeaderHidden || search.value.length > 0 || !collapsedGroups.value[group.id]
const rowCount = Math.ceil(group.screenshots.length / screenshotColumnCount.value)
const gridHeight =
rowCount === 0
? SCREENSHOT_GRID_MIN_HEIGHT
: rowCount * screenshotCardHeight.value + Math.max(0, rowCount - 1) * SCREENSHOT_GRID_GAP
const headerHeight = isHeaderHidden ? 0 : SCREENSHOT_GROUP_HEADER_HEIGHT
const gridTop = top + headerHeight + SCREENSHOT_GROUP_CONTENT_SPACING
const height =
headerHeight +
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
SCREENSHOT_GROUP_SPACING
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
top += height
}
return layouts
})
const screenshotListHeight = computed(() => {
const lastGroup = screenshotGroupLayouts.value[screenshotGroupLayouts.value.length - 1]
return lastGroup ? lastGroup.top + lastGroup.height : 0
})
const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
const viewportStart = hasViewport
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
: 0
const viewportEnd = hasViewport
? screenshotListScrollTop.value + screenshotViewportHeight.value + SCREENSHOT_GROUP_OVERSCAN
: SCREENSHOT_GROUP_OVERSCAN
return screenshotGroupLayouts.value
.filter((layout) => layout.top + layout.height >= viewportStart && layout.top <= viewportEnd)
.map((layout) => {
if (!layout.isOpen || layout.group.screenshots.length === 0) {
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
}
const rowCount = Math.ceil(layout.group.screenshots.length / screenshotColumnCount.value)
const firstRow = Math.min(
rowCount,
Math.max(0, Math.floor((viewportStart - layout.gridTop) / screenshotRowHeight.value)),
)
const lastRow = Math.min(
rowCount,
Math.max(
firstRow,
Math.ceil(
(viewportEnd - layout.gridTop + SCREENSHOT_GRID_GAP) / screenshotRowHeight.value,
),
),
)
const firstScreenshot = firstRow * screenshotColumnCount.value
const lastScreenshot = lastRow * screenshotColumnCount.value
return {
...layout,
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
virtualGridTop: firstRow * screenshotRowHeight.value,
}
})
})
const previewItems = computed(() =>
filteredScreenshots.value.map((screenshot) => ({
id: getSelectionKey(screenshot),
@@ -959,10 +1089,27 @@ async function revealScreenshot(id: string) {
if (group) setGroupCollapsed(group.id, false)
await nextTick()
const card = document.querySelector<HTMLElement>(`[data-screenshot-id="${CSS.escape(id)}"]`)
card?.scrollIntoView({ behavior: 'smooth', block: 'center' })
card?.focus()
await waitForScreenshotViewport()
highlightedScreenshotId.value = id
const layout = screenshotGroupLayouts.value.find((candidate) => candidate.group.id === group?.id)
const screenshotIndex = layout?.group.screenshots.findIndex((screenshot) => screenshot.id === id)
const scrollTarget = screenshotScrollContainer.value
if (layout && screenshotIndex !== undefined && screenshotIndex >= 0 && scrollTarget) {
const row = Math.floor(screenshotIndex / screenshotColumnCount.value)
const top = Math.max(
0,
screenshotListOffset.value +
layout.gridTop +
row * screenshotRowHeight.value +
screenshotCardHeight.value / 2 -
screenshotViewportHeight.value / 2,
)
scrollTarget.scrollTo({ top, behavior: 'smooth' })
}
const card = await waitForScreenshotCard(id)
card?.focus({ preventScroll: true })
revealTimeout = setTimeout(() => {
if (highlightedScreenshotId.value === id) highlightedScreenshotId.value = undefined
if (revealedScreenshotId.value === id) revealedScreenshotId.value = undefined
@@ -970,6 +1117,23 @@ async function revealScreenshot(id: string) {
}, 2400)
}
async function waitForScreenshotViewport() {
for (let frame = 0; frame < 10; frame++) {
if (screenshotListContainer.value && screenshotScrollContainer.value) return
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}
async function waitForScreenshotCard(id: string) {
const selector = `[data-screenshot-id="${CSS.escape(id)}"]`
for (let frame = 0; frame < 60; frame++) {
const card = screenshotListContainer.value?.querySelector<HTMLElement>(selector)
if (card) return card
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
return undefined
}
async function confirmDelete() {
const screenshot = screenshotToDelete.value
if (!screenshot) return
@@ -1171,6 +1335,7 @@ watch(activeDropGroupId, (groupId) => {
onBeforeUnmount(() => {
clearGroupHoverOpenTimeout()
if (revealTimeout) clearTimeout(revealTimeout)
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
copiedResetTimeouts.clear()
})
@@ -1312,65 +1477,85 @@ onBeforeUnmount(() => {
@drag-over="handleDragOver"
@drag-end="handleDragEnd"
>
<div class="flex flex-col">
<ScreenshotGroupSection
v-for="group in groupedScreenshots"
:id="group.id"
<div
ref="screenshotListContainer"
class="relative w-full"
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
>
<div
v-for="{
group,
top,
gridHeight,
renderedScreenshots,
virtualGridTop,
} in visibleScreenshotGroups"
:key="group.id"
:title="group.title"
:screenshots="group.screenshots"
:selected-keys="selectedKeys"
:selection-active="selectionActive"
:active-dragged-keys="activeDraggedKeys"
:show-drop-outline="activeDropGroupId === group.id && canDropScreenshotsOnGroup(group)"
:can-drag="groupBy === 'custom' || (isGlobal && groupBy === 'instance')"
:drop-instance-id="groupBy === 'instance' ? group.dropInstanceId : undefined"
:drop-custom-group="groupBy === 'custom'"
:drop-custom-group-id="group.customGroupId ?? undefined"
:show-instance-name="isGlobal && groupBy !== 'instance'"
:highlighted-screenshot-id="highlightedScreenshotId"
:copied-screenshot-ids="copiedScreenshotIds"
:animate-entry="!regrouping"
:force-open="search.length > 0"
:hide-header="groupBy === 'none'"
:editable-title="Boolean(group.customGroupId)"
:start-editing-title="groupIdPendingNameEdit === group.customGroupId"
:max-title-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
:validate-title="validateCustomGroupName"
:on-title-change="(name: string) => renameCustomGroup(group.customGroupId, name)"
:collapsed="Boolean(collapsedGroups[group.id])"
@update:collapsed="(value) => setGroupCollapsed(group.id, value)"
@activate="activateScreenshot"
@toggle-selection="toggleScreenshotSelection"
@copy="copyScreenshot"
@edit="editScreenshot"
@more="showScreenshotOptions"
class="absolute inset-x-0 transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none"
:style="{ transform: `translateY(${top}px)` }"
>
<template v-if="group.customGroupId" #actions="{ startEditing }">
<div
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<IconButton
v-tooltip="formatMessage(messages.editGroup)"
:label="formatMessage(messages.editGroup)"
type="quiet"
size="sm"
@click.stop="startEditing"
<ScreenshotGroupSection
:id="group.id"
:title="group.title"
:screenshots="group.screenshots"
:rendered-screenshots="renderedScreenshots"
:virtual-grid-height="gridHeight"
:virtual-grid-top="virtualGridTop"
:selected-keys="selectedKeys"
:selection-active="selectionActive"
:active-dragged-keys="activeDraggedKeys"
:show-drop-outline="
activeDropGroupId === group.id && canDropScreenshotsOnGroup(group)
"
:can-drag="groupBy === 'custom' || (isGlobal && groupBy === 'instance')"
:drop-instance-id="groupBy === 'instance' ? group.dropInstanceId : undefined"
:drop-custom-group="groupBy === 'custom'"
:drop-custom-group-id="group.customGroupId ?? undefined"
:show-instance-name="isGlobal && groupBy !== 'instance'"
:highlighted-screenshot-id="highlightedScreenshotId"
:copied-screenshot-ids="copiedScreenshotIds"
:animate-entry="!regrouping && !screenshotsScrolling"
:force-open="search.length > 0"
:hide-header="groupBy === 'none'"
:editable-title="Boolean(group.customGroupId)"
:start-editing-title="groupIdPendingNameEdit === group.customGroupId"
:max-title-length="MAX_INSTANCE_GROUP_NAME_LENGTH"
:validate-title="validateCustomGroupName"
:on-title-change="(name: string) => renameCustomGroup(group.customGroupId, name)"
:collapsed="Boolean(collapsedGroups[group.id])"
@update:collapsed="(value) => setGroupCollapsed(group.id, value)"
@activate="activateScreenshot"
@toggle-selection="toggleScreenshotSelection"
@copy="copyScreenshot"
@edit="editScreenshot"
@more="showScreenshotOptions"
>
<template v-if="group.customGroupId" #actions="{ startEditing }">
<div
class="flex shrink-0 items-center opacity-0 transition-opacity duration-250 group-hover/header:opacity-100 focus-within:opacity-100"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
@click.stop="requestCustomGroupDeletion(group.customGroupId)"
>
<TrashIcon />
</IconButton>
</div>
</template>
</ScreenshotGroupSection>
<IconButton
v-tooltip="formatMessage(messages.editGroup)"
:label="formatMessage(messages.editGroup)"
type="quiet"
size="sm"
@click.stop="startEditing"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
size="sm"
@click.stop="requestCustomGroupDeletion(group.customGroupId)"
>
<TrashIcon />
</IconButton>
</div>
</template>
</ScreenshotGroupSection>
</div>
</div>
<Teleport to="body">
<div class="pointer-events-none fixed inset-0 z-[9999]">