mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
fix: screenshots page lag + better virtualization pass
This commit is contained in:
@@ -3,11 +3,16 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
|
||||
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
|
||||
import { defineMessages, IconButton, useFormatDateTime, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
defineMessages,
|
||||
IconButton,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
const loadedScreenshotUrls = new Set<string>()
|
||||
|
||||
const props = defineProps<{
|
||||
screenshot: InstanceScreenshot
|
||||
@@ -29,9 +34,12 @@ const emit = defineEmits<{
|
||||
|
||||
const card = ref<HTMLElement>()
|
||||
const image = ref<HTMLImageElement>()
|
||||
const loaded = ref(loadedScreenshotUrls.has(props.screenshot.url))
|
||||
const imageReady = ref(false)
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugImage = useDebugLogger('Screenshots:Card')
|
||||
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
let loadStartedAt = performance.now()
|
||||
let loadGeneration = 0
|
||||
const messages = defineMessages({
|
||||
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
|
||||
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
|
||||
@@ -69,19 +77,86 @@ function activate(event: MouseEvent | KeyboardEvent) {
|
||||
emit('activate', event)
|
||||
}
|
||||
|
||||
function markImageLoaded() {
|
||||
loadedScreenshotUrls.add(props.screenshot.url)
|
||||
loaded.value = true
|
||||
async function markImageLoaded() {
|
||||
const loadedImage = image.value
|
||||
const loadedUrl = props.screenshot.url
|
||||
const generation = loadGeneration
|
||||
if (!loadedImage) return
|
||||
|
||||
try {
|
||||
await loadedImage.decode()
|
||||
} catch {
|
||||
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
|
||||
})
|
||||
|
||||
if (
|
||||
generation !== loadGeneration ||
|
||||
image.value !== loadedImage ||
|
||||
props.screenshot.url !== loadedUrl
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasReady = imageReady.value
|
||||
imageReady.value = true
|
||||
if (!wasReady) {
|
||||
debugImage('image loaded', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
naturalWidth: image.value?.naturalWidth,
|
||||
naturalHeight: image.value?.naturalHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function markImageFailed(event: Event) {
|
||||
debugImage('image failed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loadDurationMs: performance.now() - loadStartedAt,
|
||||
event,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugImage('mounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
complete: image.value?.complete,
|
||||
})
|
||||
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadGeneration += 1
|
||||
debugImage('unmounted', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
url: props.screenshot.url,
|
||||
loaded: imageReady.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.screenshot.url,
|
||||
(url) => {
|
||||
loaded.value = loadedScreenshotUrls.has(url)
|
||||
(url, previousUrl) => {
|
||||
loadGeneration += 1
|
||||
loadStartedAt = performance.now()
|
||||
imageReady.value = false
|
||||
debugImage('source changed', {
|
||||
id: props.screenshot.id,
|
||||
fileName: props.screenshot.file_name,
|
||||
previousUrl,
|
||||
url,
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -91,7 +166,7 @@ watch(
|
||||
ref="card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="group relative aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
|
||||
:class="{
|
||||
'!border-contrast brightness-110': selected,
|
||||
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
|
||||
@@ -115,7 +190,7 @@ watch(
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-0.5 top-0 z-[2] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
|
||||
:aria-label="
|
||||
formatMessage(selected ? messages.deselect : messages.select, {
|
||||
name: screenshot.file_name,
|
||||
@@ -134,19 +209,25 @@ watch(
|
||||
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!loaded" class="absolute inset-0 animate-pulse bg-surface-3" />
|
||||
<img
|
||||
ref="image"
|
||||
:src="screenshot.url"
|
||||
:alt="screenshot.file_name"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
draggable="false"
|
||||
class="h-full w-full object-cover transition duration-200"
|
||||
:class="loaded ? 'opacity-100' : 'opacity-0'"
|
||||
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
|
||||
:class="imageReady ? 'opacity-100' : 'opacity-0'"
|
||||
@load="markImageLoaded"
|
||||
@error="markImageFailed"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 bg-button-bg"
|
||||
:class="{ 'animate-pulse': !imageReady }"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-contrast opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
|
||||
@@ -193,3 +274,9 @@ watch(
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.screenshot-card-fade {
|
||||
transition: opacity 350ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useDroppable } from '@dnd-kit/vue'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { InstanceScreenshot } from '@/helpers/instance'
|
||||
|
||||
@@ -27,7 +27,6 @@ const props = defineProps<{
|
||||
highlightedScreenshotId?: string
|
||||
copiedScreenshotIds: ReadonlySet<string>
|
||||
forceOpen: boolean
|
||||
animateEntry: boolean
|
||||
hideHeader?: boolean
|
||||
editableTitle?: boolean
|
||||
startEditingTitle?: boolean
|
||||
@@ -39,6 +38,7 @@ const props = defineProps<{
|
||||
const collapsed = defineModel<boolean>('collapsed', { required: true })
|
||||
const dropTarget = ref<HTMLElement>()
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Group')
|
||||
const messages = defineMessages({
|
||||
emptyGroup: {
|
||||
id: 'app.screenshots.group.empty',
|
||||
@@ -60,14 +60,42 @@ const visibleGridStyle = computed(() =>
|
||||
: { transform: `translateY(${props.virtualGridTop}px)` },
|
||||
)
|
||||
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
async function logGeometry(reason: string) {
|
||||
await nextTick()
|
||||
const rect = dropTarget.value?.getBoundingClientRect()
|
||||
debugLayout(reason, {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
collapsed: collapsed.value,
|
||||
shouldShowGrid: shouldShowGrid.value,
|
||||
renderGrid: renderGrid.value,
|
||||
screenshotCount: props.screenshots.length,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
|
||||
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
|
||||
virtualGridHeight: props.virtualGridHeight,
|
||||
virtualGridTop: props.virtualGridTop,
|
||||
actual: rect
|
||||
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.renderedScreenshots ?? props.screenshots,
|
||||
(screenshots) => {
|
||||
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
|
||||
void logGeometry('rendered screenshots changed')
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.virtualGridHeight, props.virtualGridTop] as const,
|
||||
() => void logGeometry('virtual grid geometry changed'),
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldShowGrid,
|
||||
(showGrid, previouslyShown) => {
|
||||
@@ -75,6 +103,7 @@ watch(
|
||||
if (showGrid) {
|
||||
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
|
||||
renderGrid.value = true
|
||||
void logGeometry('grid shown')
|
||||
return
|
||||
}
|
||||
if (!previouslyShown) {
|
||||
@@ -83,13 +112,41 @@ watch(
|
||||
}
|
||||
unmountGridTimeout = setTimeout(() => {
|
||||
renderGrid.value = false
|
||||
void logGeometry('grid unmounted after collapse')
|
||||
unmountGridTimeout = undefined
|
||||
}, 300)
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', { id: props.id, title: props.title })
|
||||
void logGeometry('mounted geometry')
|
||||
if (dropTarget.value) {
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const rect = entry?.target.getBoundingClientRect()
|
||||
debugLayout('actual size changed', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
width: rect?.width,
|
||||
height: rect?.height,
|
||||
expectedHeight:
|
||||
(props.hideHeader ? 0 : 40) +
|
||||
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
|
||||
12,
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(dropTarget.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', {
|
||||
id: props.id,
|
||||
title: props.title,
|
||||
renderedCount: visibleScreenshots.value.length,
|
||||
})
|
||||
resizeObserver?.disconnect()
|
||||
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
|
||||
})
|
||||
|
||||
@@ -149,19 +206,10 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
<slot name="actions" :start-editing="startEditing" />
|
||||
</template>
|
||||
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
<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"
|
||||
>
|
||||
<ScreenshotCard
|
||||
v-for="screenshot in visibleScreenshots"
|
||||
@@ -188,7 +236,7 @@ function getSelectionKey(screenshot: InstanceScreenshot) {
|
||||
>
|
||||
{{ formatMessage(messages.emptyGroup) }}
|
||||
</p>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</ScreenshotSection>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type ImageViewerEditorSavePayload,
|
||||
injectNotificationManager,
|
||||
ReadyTransition,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useReadyState,
|
||||
useScrollViewport,
|
||||
@@ -109,6 +110,7 @@ type ScreenshotDropData = {
|
||||
}
|
||||
|
||||
type ScreenshotGroupLayout = {
|
||||
id: string
|
||||
group: ScreenshotGroupData
|
||||
top: number
|
||||
height: number
|
||||
@@ -117,7 +119,7 @@ type ScreenshotGroupLayout = {
|
||||
gridTop: number
|
||||
}
|
||||
|
||||
type VisibleScreenshotGroupLayout = ScreenshotGroupLayout & {
|
||||
type VirtualizedScreenshotGroupLayout = ScreenshotGroupLayout & {
|
||||
renderedScreenshots: InstanceScreenshot[]
|
||||
virtualGridTop: number
|
||||
}
|
||||
@@ -126,7 +128,7 @@ 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_GROUP_OVERSCAN = 2000
|
||||
const SCREENSHOT_GRID_MIN_HEIGHT = 45
|
||||
const FALLBACK_SCREENSHOT_CARD_WIDTH = 320
|
||||
|
||||
@@ -173,8 +175,6 @@ const selectedKeys = ref(new Set<string>())
|
||||
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)
|
||||
@@ -197,7 +197,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
let screenshotsScrollIdleTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
const debugLayout = useDebugLogger('Screenshots:Layout')
|
||||
let screenshotsScrollLogFrame: number | undefined
|
||||
const {
|
||||
listContainer: screenshotListContainer,
|
||||
containerOffset: screenshotListOffset,
|
||||
@@ -206,12 +207,18 @@ const {
|
||||
viewportHeight: screenshotViewportHeight,
|
||||
} = useScrollViewport({
|
||||
onScroll: () => {
|
||||
screenshotsScrolling.value = true
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
screenshotsScrollIdleTimeout = setTimeout(() => {
|
||||
screenshotsScrolling.value = false
|
||||
screenshotsScrollIdleTimeout = undefined
|
||||
}, 120)
|
||||
if (screenshotsScrollLogFrame === undefined) {
|
||||
screenshotsScrollLogFrame = requestAnimationFrame(() => {
|
||||
screenshotsScrollLogFrame = undefined
|
||||
debugLayout('scroll', {
|
||||
groupBy: groupBy.value,
|
||||
relativeScrollTop: screenshotListScrollTop.value,
|
||||
listOffset: screenshotListOffset.value,
|
||||
viewportHeight: screenshotViewportHeight.value,
|
||||
listWidth: screenshotListWidth.value,
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
const { width: screenshotListWidth } = useElementSize(screenshotListContainer)
|
||||
@@ -546,19 +553,14 @@ const screenshotGroupLayouts = computed<ScreenshotGroupLayout[]>(() => {
|
||||
(isOpen ? SCREENSHOT_GROUP_CONTENT_SPACING + gridHeight : 0) +
|
||||
SCREENSHOT_GROUP_SPACING
|
||||
|
||||
layouts.push({ group, top, height, isOpen, gridHeight, gridTop })
|
||||
layouts.push({ id: group.id, 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 virtualizedScreenshotGroups = computed<VirtualizedScreenshotGroupLayout[]>(() => {
|
||||
const hasViewport = Boolean(screenshotListContainer.value && screenshotScrollContainer.value)
|
||||
const viewportStart = hasViewport
|
||||
? Math.max(0, screenshotListScrollTop.value - SCREENSHOT_GROUP_OVERSCAN)
|
||||
@@ -567,36 +569,90 @@ const visibleScreenshotGroups = computed<VisibleScreenshotGroupLayout[]>(() => {
|
||||
? 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 }
|
||||
}
|
||||
return screenshotGroupLayouts.value.map((layout) => {
|
||||
const isInRenderRange = layout.top + layout.height >= viewportStart && layout.top <= viewportEnd
|
||||
if (!layout.isOpen || layout.group.screenshots.length === 0) {
|
||||
return { ...layout, renderedScreenshots: [], virtualGridTop: 0 }
|
||||
}
|
||||
if (!isInRenderRange) {
|
||||
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
|
||||
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,
|
||||
}
|
||||
return {
|
||||
...layout,
|
||||
renderedScreenshots: layout.group.screenshots.slice(firstScreenshot, lastScreenshot),
|
||||
virtualGridTop: firstRow * screenshotRowHeight.value,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
[groupBy, screenshotListWidth, windowWidth, screenshotColumnCount, screenshotCardHeight],
|
||||
([currentGroupBy, listWidth, currentWindowWidth, columns, cardHeight], previous) => {
|
||||
debugLayout('responsive geometry changed', {
|
||||
groupBy: currentGroupBy,
|
||||
listWidth,
|
||||
windowWidth: currentWindowWidth,
|
||||
columns,
|
||||
cardHeight,
|
||||
previous,
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
screenshotGroupLayouts,
|
||||
(layouts) => {
|
||||
debugLayout('group layouts changed', {
|
||||
groupBy: groupBy.value,
|
||||
groupCount: layouts.length,
|
||||
totalHeight: layouts.at(-1) ? layouts.at(-1)!.top + layouts.at(-1)!.height : 0,
|
||||
layouts: layouts.map((layout) => ({
|
||||
id: layout.id,
|
||||
top: layout.top,
|
||||
height: layout.height,
|
||||
gridTop: layout.gridTop,
|
||||
gridHeight: layout.gridHeight,
|
||||
isOpen: layout.isOpen,
|
||||
screenshotCount: layout.group.screenshots.length,
|
||||
})),
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(virtualizedScreenshotGroups, (groups) => {
|
||||
debugLayout('render window changed', {
|
||||
groupBy: groupBy.value,
|
||||
relativeScrollTop: screenshotListScrollTop.value,
|
||||
viewportHeight: screenshotViewportHeight.value,
|
||||
renderedGroups: groups
|
||||
.filter((group) => group.renderedScreenshots.length > 0)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
top: group.top,
|
||||
virtualGridTop: group.virtualGridTop,
|
||||
renderedCount: group.renderedScreenshots.length,
|
||||
firstScreenshotId: group.renderedScreenshots.at(0)?.id,
|
||||
lastScreenshotId: group.renderedScreenshots.at(-1)?.id,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
const previewItems = computed(() =>
|
||||
@@ -685,10 +741,8 @@ watch(groupBy, async (currentGroupBy, previousGroupBy) => {
|
||||
if (currentGroupBy === previousGroupBy) return
|
||||
|
||||
const previousPositions = getScreenshotCardPositions()
|
||||
regrouping.value = true
|
||||
await nextTick()
|
||||
animateScreenshotCardsFrom(previousPositions)
|
||||
regrouping.value = false
|
||||
})
|
||||
|
||||
let handledFocus: string | undefined
|
||||
@@ -1335,7 +1389,7 @@ watch(activeDropGroupId, (groupId) => {
|
||||
onBeforeUnmount(() => {
|
||||
clearGroupHoverOpenTimeout()
|
||||
if (revealTimeout) clearTimeout(revealTimeout)
|
||||
if (screenshotsScrollIdleTimeout) clearTimeout(screenshotsScrollIdleTimeout)
|
||||
if (screenshotsScrollLogFrame !== undefined) cancelAnimationFrame(screenshotsScrollLogFrame)
|
||||
for (const timeout of copiedResetTimeouts.values()) clearTimeout(timeout)
|
||||
copiedResetTimeouts.clear()
|
||||
})
|
||||
@@ -1479,20 +1533,21 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div
|
||||
ref="screenshotListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ height: `${screenshotListHeight}px`, overflowAnchor: 'none' }"
|
||||
class="w-full"
|
||||
:style="{
|
||||
overflowAnchor: 'none',
|
||||
visibility: screenshotListWidth > 0 ? 'visible' : 'hidden',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="{
|
||||
id,
|
||||
group,
|
||||
top,
|
||||
gridHeight,
|
||||
renderedScreenshots,
|
||||
virtualGridTop,
|
||||
} in visibleScreenshotGroups"
|
||||
:key="group.id"
|
||||
class="absolute inset-x-0 transition-transform duration-300 ease-in-out will-change-transform motion-reduce:transition-none"
|
||||
:style="{ transform: `translateY(${top}px)` }"
|
||||
} in virtualizedScreenshotGroups"
|
||||
:key="id"
|
||||
>
|
||||
<ScreenshotGroupSection
|
||||
:id="group.id"
|
||||
@@ -1514,7 +1569,6 @@ onBeforeUnmount(() => {
|
||||
: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)"
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
defineMessages,
|
||||
InlineEditableText,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -37,6 +38,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debugLayout = useDebugLogger('Screenshots:Section')
|
||||
const accordion = ref<InstanceType<typeof Accordion>>()
|
||||
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
|
||||
const titleModel = ref(props.title)
|
||||
@@ -46,6 +48,11 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
debugLayout('toggle requested', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
isOpen: accordion.value?.isOpen,
|
||||
})
|
||||
if (accordion.value?.isOpen) {
|
||||
accordion.value.close()
|
||||
} else {
|
||||
@@ -53,6 +60,30 @@ function toggle() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
debugLayout('opened', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', false)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
debugLayout('closed', { title: props.title, count: props.count })
|
||||
emit('update:collapsed', true)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
debugLayout('mounted', {
|
||||
title: props.title,
|
||||
count: props.count,
|
||||
collapsed: props.collapsed,
|
||||
hideHeader: props.hideHeader,
|
||||
forceOpen: props.forceOpen,
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
debugLayout('unmounted', { title: props.title, count: props.count })
|
||||
})
|
||||
|
||||
async function startTitleEditing() {
|
||||
if (!props.editable) return
|
||||
await titleInput.value?.startEditing()
|
||||
@@ -127,8 +158,8 @@ watch(
|
||||
:force-open="forceOpen"
|
||||
overflow-visible
|
||||
class="w-full"
|
||||
@on-open="emit('update:collapsed', false)"
|
||||
@on-close="emit('update:collapsed', true)"
|
||||
@on-open="handleOpen"
|
||||
@on-close="handleClose"
|
||||
>
|
||||
<div class="mt-2.5">
|
||||
<slot />
|
||||
|
||||
Reference in New Issue
Block a user