fix(knowledge): replace IntersectionObserver with scroll event; increase card height
- Swap IntersectionObserver (race-prone, fired immediately on creation) for a passive scroll listener on the grid container — eliminates duplicate page loads caused by observer re-creation after DOM updates - Increase card min-height 100px → 160px so tags + snippet are visible - Increase snippet line-clamp 3 → 4 for more content preview Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, onMounted, onUnmounted, nextTick, watchEffect } from "vue";
|
import { ref, watch, onMounted, onUnmounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPatch } from "@/api/client";
|
import { apiGet, apiPatch } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
import { fmtCompact } from "@/utils/dateFormat";
|
||||||
@@ -244,23 +244,16 @@ function formatDate(iso: string): string {
|
|||||||
|
|
||||||
// ─── Infinite scroll ──────────────────────────────────────────────────────────
|
// ─── Infinite scroll ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const sentinelEl = ref<HTMLElement | null>(null);
|
|
||||||
const cardGridEl = ref<HTMLElement | null>(null);
|
const cardGridEl = ref<HTMLElement | null>(null);
|
||||||
let scrollObserver: IntersectionObserver | null = null;
|
|
||||||
|
|
||||||
function setupScrollObserver() {
|
function onGridScroll() {
|
||||||
if (scrollObserver) scrollObserver.disconnect();
|
const el = cardGridEl.value;
|
||||||
if (!sentinelEl.value || !cardGridEl.value) return;
|
if (!el || loading.value || items.value.length >= total.value) return;
|
||||||
scrollObserver = new IntersectionObserver(
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 300;
|
||||||
(entries) => {
|
if (nearBottom) {
|
||||||
if (entries[0].isIntersecting && !loading.value && items.value.length < total.value) {
|
page.value++;
|
||||||
page.value++;
|
fetchItems();
|
||||||
fetchItems();
|
}
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: cardGridEl.value, rootMargin: "200px" }
|
|
||||||
);
|
|
||||||
scrollObserver.observe(sentinelEl.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────────
|
||||||
@@ -269,16 +262,10 @@ onMounted(() => {
|
|||||||
fetchItems(true);
|
fetchItems(true);
|
||||||
fetchTags();
|
fetchTags();
|
||||||
fetchTodayBar();
|
fetchTodayBar();
|
||||||
nextTick(() => setupScrollObserver());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (searchDebounce) clearTimeout(searchDebounce);
|
if (searchDebounce) clearTimeout(searchDebounce);
|
||||||
scrollObserver?.disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
watchEffect(() => {
|
|
||||||
if (sentinelEl.value && cardGridEl.value) setupScrollObserver();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -380,8 +367,8 @@ watchEffect(() => {
|
|||||||
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
|
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card grid (sentinel lives inside so IntersectionObserver root works) -->
|
<!-- Card grid -->
|
||||||
<div v-else ref="cardGridEl" class="card-grid">
|
<div v-else ref="cardGridEl" class="card-grid" @scroll.passive="onGridScroll">
|
||||||
<div
|
<div
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -446,10 +433,8 @@ watchEffect(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sentinel after all cards — IntersectionObserver triggers next page load -->
|
<!-- Loading indicator when fetching additional pages -->
|
||||||
<div ref="sentinelEl" class="scroll-sentinel">
|
<div v-if="loading && items.length > 0" class="load-more-indicator">Loading…</div>
|
||||||
<span v-if="loading && items.length > 0" class="sentinel-loading">Loading…</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -730,7 +715,7 @@ watchEffect(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 100px;
|
min-height: 160px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.k-card:hover {
|
.k-card:hover {
|
||||||
@@ -777,7 +762,7 @@ watchEffect(() => {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
@@ -877,15 +862,11 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
|
||||||
|
|
||||||
/* ── Infinite scroll sentinel ────────────────────────────── */
|
/* ── Load more indicator ─────────────────────────────────── */
|
||||||
.scroll-sentinel {
|
.load-more-indicator {
|
||||||
grid-column: 1 / -1; /* span all columns so it's not a card-sized slot */
|
grid-column: 1 / -1;
|
||||||
height: 40px;
|
padding: 16px;
|
||||||
display: flex;
|
text-align: center;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.sentinel-loading {
|
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-muted);
|
color: var(--color-muted);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user