feat: add scroll state persistence to RantFeed with per-page state keys

Implement saveScrollState, restoreScrollState, and clearScrollState methods in RantFeed component, storing anchor rant ID and scroll offset per page. Wire up initFeed calls in CollabsPage, HomePage, StoriesPage, WeeklyPage, and SearchPage to set unique state keys (feed_state_collabs, feed_state_home, etc.) enabling seamless scroll restoration on navigation back.
This commit is contained in:
2025-12-12 18:26:17 +00:00
parent fefd47f703
commit f76c44a7e6
6 changed files with 171 additions and 2 deletions
+129 -2
View File
@@ -21,11 +21,130 @@ class RantFeed extends BaseComponent {
this.sort = this.getAttr('sort') || 'recent';
this.feedType = this.getAttr('feed-type') || 'rants';
this.blockChangeHandler = () => this.render();
this.stateKey = null;
this.pendingRestore = null;
this.render();
this.bindEvents();
}
setStateKey(key) {
this.stateKey = key;
}
getTopVisibleRant() {
const cards = this.$$('rant-card');
if (!cards || cards.length === 0) return null;
for (const card of cards) {
const rect = card.getBoundingClientRect();
if (rect.top >= -rect.height && rect.top < window.innerHeight) {
const rantId = card.getRantId();
const offsetFromTop = rect.top;
return { rantId, offsetFromTop };
}
}
return null;
}
saveScrollState() {
if (!this.stateKey) return;
const topRant = this.getTopVisibleRant();
if (!topRant) return;
const state = {
anchorRantId: topRant.rantId,
offsetFromTop: topRant.offsetFromTop,
skip: this.skip,
sort: this.sort
};
this.getStorage()?.set(this.stateKey, state);
}
clearScrollState() {
if (!this.stateKey) return;
this.getStorage()?.remove(this.stateKey);
}
async restoreScrollState() {
if (!this.stateKey) return false;
const state = this.getStorage()?.get(this.stateKey);
if (!state || state.sort !== this.sort) {
this.clearScrollState();
return false;
}
this.pendingRestore = state;
await this.loadWithLimit(state.skip || this.limit);
return true;
}
scrollToAnchor() {
if (!this.pendingRestore) return;
const { anchorRantId, offsetFromTop } = this.pendingRestore;
this.pendingRestore = null;
requestAnimationFrame(() => {
const card = this.$(`rant-card[rant-id="${anchorRantId}"]`);
if (card) {
const rect = card.getBoundingClientRect();
const scrollY = window.scrollY + rect.top - offsetFromTop;
window.scrollTo(0, Math.max(0, scrollY));
}
this.clearScrollState();
});
}
async loadWithLimit(limit) {
if (this.isLoading) return;
this.rants = [];
this.skip = 0;
this.hasMore = true;
this.isLoading = true;
this.updateLoadingState();
try {
let result;
const api = this.getApi();
switch (this.feedType) {
case 'weekly':
result = await api?.getWeeklyRants(this.sort, limit, 0);
break;
case 'collabs':
result = await api?.getCollabs(this.sort, limit, 0);
break;
case 'stories':
result = await api?.getStories(this.sort, limit, 0);
break;
case 'search':
break;
default:
result = await api?.getRants(this.sort, limit, 0);
}
if (result?.success) {
const newRants = result.rants || [];
this.rants = newRants;
this.skip = newRants.length;
this.hasMore = newRants.length >= limit;
} else {
this.hasMore = false;
}
} catch (error) {
this.hasMore = false;
} finally {
this.isLoading = false;
this.render();
this.scrollToAnchor();
}
}
getBlock() {
return window.app?.block;
}
@@ -207,11 +326,15 @@ class RantFeed extends BaseComponent {
this.intersectionObserver = observer;
}
onConnected() {
this.load(true);
async onConnected() {
const restored = await this.restoreScrollState();
if (!restored) {
this.load(true);
}
}
onDisconnected() {
this.saveScrollState();
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
@@ -223,10 +346,12 @@ class RantFeed extends BaseComponent {
onAttributeChanged(name, oldValue, newValue) {
if (name === 'sort' && oldValue !== newValue) {
this.sort = newValue;
this.clearScrollState();
this.load(true);
}
if (name === 'feed-type' && oldValue !== newValue) {
this.feedType = newValue;
this.clearScrollState();
this.load(true);
}
}
@@ -234,12 +359,14 @@ class RantFeed extends BaseComponent {
setSort(sort) {
this.sort = sort;
this.setAttr('sort', sort);
this.clearScrollState();
this.load(true);
}
setFeedType(type) {
this.feedType = type;
this.setAttr('feed-type', type);
this.clearScrollState();
this.load(true);
}