The buffered cascade revealed tiles on an 80ms timer regardless of image load, so the flip-in animation played on a gray placeholder and the thumbnail popped in afterward. Worse, MasonryGrid ALSO applied a per-index animation-delay (index×70ms) that compounded on top of the insert cadence, so the cascade visibly dragged and desynced as it grew. Now the producer preloads each queued thumbnail (decode pipelined ahead) and the consumer awaits that decode before pushing the item — every tile animates in fully loaded, strictly one at a time. Drop the compounding CSS stagger; the store's one-item-at-a-time push is the sole pacer, so each tile animates the instant it mounts. New utils/preloadImage.js (load+decode+timeout gate). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
148 lines
5.3 KiB
Vue
148 lines
5.3 KiB
Vue
<template>
|
||
<div class="fc-masonry" ref="containerEl">
|
||
<div class="fc-masonry__cols">
|
||
<div v-for="(col, ci) in columns" :key="ci" class="fc-masonry__col">
|
||
<button
|
||
v-for="item in col" :key="item.id"
|
||
class="fc-masonry__item"
|
||
:class="{ 'fc-masonry__item--anim': shouldAnimate(item) }"
|
||
type="button"
|
||
@click="$emit('open', item.id)"
|
||
>
|
||
<img
|
||
:src="item.thumbnail_url" :alt="`image ${item.id}`"
|
||
loading="lazy"
|
||
:style="aspectStyle(item)"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="fc-masonry__sentinel">
|
||
<v-progress-circular indeterminate color="accent" size="28" />
|
||
</div>
|
||
<div v-else-if="hasMore" ref="sentinelEl" class="fc-masonry__sentinel" />
|
||
<div v-else-if="items.length" class="fc-masonry__end text-caption">
|
||
End.
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { usePolyMasonry } from '../../composables/usePolyMasonry.js'
|
||
import { useInfiniteScroll } from '../../composables/useInfiniteScroll.js'
|
||
|
||
const props = defineProps({
|
||
items: { type: Array, default: () => [] },
|
||
loading: { type: Boolean, default: false },
|
||
hasMore: { type: Boolean, default: false },
|
||
// Items at indices >= animateFromIndex get the stagger fade-in. Opt-in
|
||
// — defaults to Infinity (no animation) so views that don't want it
|
||
// (ArtistView, etc.) don't pay the layout-shift cost. ShowcaseView
|
||
// uses 0 on initial load / shuffle and prevCount on infinite-scroll
|
||
// appends.
|
||
animateFromIndex: { type: Number, default: Number.POSITIVE_INFINITY },
|
||
})
|
||
const emit = defineEmits(['load-more', 'open'])
|
||
|
||
const containerEl = ref(null)
|
||
const sentinelEl = ref(null)
|
||
const { columnCount, distribute } = usePolyMasonry(containerEl)
|
||
|
||
const columns = computed(() => distribute(props.items, columnCount.value))
|
||
|
||
// id → index lookup so we can derive the stagger from natural reading
|
||
// order even after the masonry distributes items across columns.
|
||
const idxById = computed(() => {
|
||
const m = new Map()
|
||
props.items.forEach((it, i) => m.set(it.id, i))
|
||
return m
|
||
})
|
||
|
||
function shouldAnimate(item) {
|
||
const idx = idxById.value.get(item.id)
|
||
return idx !== undefined && idx >= props.animateFromIndex
|
||
}
|
||
|
||
function aspectStyle(item) {
|
||
const w = Number(item.width)
|
||
const h = Number(item.height)
|
||
if (!w || !h) return {}
|
||
return { aspectRatio: `${w} / ${h}` }
|
||
}
|
||
|
||
// Larger rootMargin than the composable default (600px) because the
|
||
// sentinel sits at the BOTTOM of the masonry container, whose height is
|
||
// the MAX of the column heights. A single tall image (long manga page,
|
||
// panorama) in one column pushes the sentinel way past the visible
|
||
// bottom of the SHORTER columns — the user reads the short-column
|
||
// bottoms long before the sentinel comes into view, and load-more
|
||
// fires too late. 2400px ≈ 2-3 screen-heights of pre-emptive trigger,
|
||
// comfortably covering typical tall-image heights. Operator-flagged
|
||
// 2026-05-30.
|
||
useInfiniteScroll(sentinelEl, () => {
|
||
if (props.hasMore && !props.loading) emit('load-more')
|
||
}, { rootMargin: '2400px' })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fc-masonry__cols { display: flex; gap: 8px; align-items: flex-start; }
|
||
.fc-masonry__col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
|
||
.fc-masonry__item {
|
||
display: block; padding: 0; border: 0; background: none;
|
||
cursor: pointer; width: 100%;
|
||
overflow: hidden; border-radius: 4px;
|
||
}
|
||
.fc-masonry__item img {
|
||
width: 100%; height: auto; display: block;
|
||
background: rgb(var(--v-theme-surface-light));
|
||
/* IR-parity hover: zoom + brighten the thumbnail (style.css ~1772). */
|
||
transition: transform 0.3s ease, filter 0.3s ease;
|
||
}
|
||
.fc-masonry__item:hover img {
|
||
transform: scale(1.03);
|
||
filter: brightness(1.1);
|
||
}
|
||
.fc-masonry__sentinel {
|
||
display: flex; justify-content: center; padding: 32px 0; min-height: 60px;
|
||
}
|
||
.fc-masonry__end { text-align: center; padding: 32px 0; }
|
||
|
||
/* Cascade entry: each tile flips up out of a backward tilt and settles
|
||
into place — more pronounced than a plain fade so the showcase reads as an
|
||
"experience" (operator-flagged 2026-05-28). The reveal is paced entirely by
|
||
the store, which pushes one fully-decoded item at a time (showcase.js); each
|
||
tile therefore animates the instant it mounts, with NO per-index CSS delay —
|
||
the old `animation-delay: index×70ms` compounded on top of the insert
|
||
cadence and made the cascade drag and desync as it grew (operator-flagged
|
||
2026-06-04). The `both` fill holds the hidden/tilted 0% state until mount;
|
||
the cubic-bezier overshoots slightly past flat then settles. Honors
|
||
prefers-reduced-motion. Tunables: tilt (-28deg), duration (0.6s). */
|
||
@keyframes fc-masonry-item-in {
|
||
0% {
|
||
opacity: 0;
|
||
transform: perspective(1000px) rotateX(-28deg) translateY(26px) scale(0.95);
|
||
}
|
||
55% { opacity: 1; }
|
||
100% {
|
||
opacity: 1;
|
||
transform: perspective(1000px) rotateX(0deg) translateY(0) scale(1);
|
||
}
|
||
}
|
||
.fc-masonry__item--anim {
|
||
transform-origin: center top;
|
||
backface-visibility: hidden;
|
||
animation: fc-masonry-item-in 0.6s cubic-bezier(0.34, 1.45, 0.64, 1) both;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.fc-masonry__item--anim {
|
||
animation: none;
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
.fc-masonry__item img { transition: none; }
|
||
.fc-masonry__item:hover img { transform: none; }
|
||
}
|
||
</style>
|