feat: Prevent scroll jumps on history refresh and pointer interaction

This commit is contained in:
Simon Cordt
2025-09-25 04:25:10 +02:00
parent 7a16691487
commit 99536010c3
2 changed files with 85 additions and 3 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
.pqueue-root{display:flex;flex-direction:column;gap:1rem;height:100%;color:var(--common-text,#e5e7eb);font-size:13px;line-height:1.4;padding:0 4px}
.pqueue-root{display:flex;flex-direction:column;gap:1rem;height:100%;color:var(--common-text,#e5e7eb);font-size:13px;line-height:1.4;padding:0 4px;overflow-anchor:none}
.pqueue-root a{color:inherit}
.pqueue-toolbar{display:flex;flex-direction:column;gap:.75rem;padding:.75rem 1rem;border-radius:12px;background:#1b1b1b;box-shadow:0 1px 2px rgba(0,0,0,.24)}
.pqueue-toolbar__row{display:flex;gap:.75rem;align-items:center;justify-content:space-between;flex-wrap:wrap}
@@ -35,7 +35,7 @@
.pqueue-icon--lg{font-size:1.35rem}
.pqueue-icon--spin{animation:pqueue-spin 1s linear infinite}
@keyframes pqueue-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.pqueue-sections{display:flex;flex-direction:column;gap:1rem;overflow:auto;padding-bottom:1rem}
.pqueue-sections{display:flex;flex-direction:column;gap:1rem;overflow:auto;padding-bottom:1rem;overflow-anchor:none}
.pqueue-card{background:#1a1a1a;backdrop-filter:blur(22px);border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,.35);padding:1rem 1.15rem;display:flex;flex-direction:column;gap:.75rem;border:1px solid rgba(82,82,91,.22)}
.pqueue-card__header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}
.pqueue-card__title-wrap{display:flex;align-items:center;gap:.6rem;color:var(--heading-text,#f9fafb)}
+83 -1
View File
@@ -415,6 +415,38 @@
if (!state.container) return;
UI.ensureAssets();
// Preserve scroll position relative to the history card to avoid jank on refresh
const prevDom = state.dom;
let _prevScrollTop = 0;
let _prevAnchorOffset = null;
let _prevScroller = null;
const findScrollContainer = (node) => {
try {
let el = node;
while (el && el !== document.body && el !== document.documentElement) {
const style = window.getComputedStyle(el);
const oy = style.overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) return el;
el = el.parentElement;
}
return document.scrollingElement || document.documentElement || document.body;
} catch (err) {
return document.scrollingElement || document.documentElement || document.body;
}
};
try {
_prevScroller = findScrollContainer(state.container);
if (_prevScroller) {
_prevScrollTop = _prevScroller.scrollTop || 0;
const prevCard = (prevDom && prevDom.historyCard) ? prevDom.historyCard : state.container.querySelector('.pqueue-card');
if (prevCard) {
const scRect = _prevScroller.getBoundingClientRect();
const cardRect = prevCard.getBoundingClientRect();
_prevAnchorOffset = cardRect.top - scRect.top;
}
}
} catch (err) { /* noop */ }
const dom = (state.dom = {});
state.container.innerHTML = "";
@@ -431,6 +463,26 @@
root.appendChild(sections);
state.container.appendChild(root);
// Expose scroller for other operations
dom.sections = sections;
// Restore scroll position relative to the history card to prevent layout shifts
requestAnimationFrame(() => {
try {
const scroller = _prevScroller || findScrollContainer(state.container);
if (!scroller) return;
const newCard = (state.dom && state.dom.historyCard) ? state.dom.historyCard : state.container.querySelector('.pqueue-card');
if (_prevAnchorOffset != null && newCard) {
const scRect2 = scroller.getBoundingClientRect();
const cardRect2 = newCard.getBoundingClientRect();
const newOffset = cardRect2.top - scRect2.top;
scroller.scrollTop = Math.max(0, _prevScrollTop + (newOffset - _prevAnchorOffset));
} else {
scroller.scrollTop = _prevScrollTop;
}
} catch (err) { /* noop */ }
});
Events.bind();
UI.updateProgressBars();
UI.refreshFilter();
@@ -1437,6 +1489,19 @@
table.addEventListener("keydown", Events.handleTableKey);
}
UI.ensureHistoryObserver();
// Short-lived render lock on pointer down/up inside our container to prevent re-renders during clicks
try {
const root = state.container;
if (root && !root._pqlockBound) {
const lock = () => { state.renderLockUntil = Date.now() + 300; };
const unlockSoon = () => { state.renderLockUntil = Date.now() + 100; };
root.addEventListener('pointerdown', lock, true);
root.addEventListener('pointerup', unlockSoon, true);
root.addEventListener('click', unlockSoon, true);
root._pqlockBound = true;
}
} catch (err) { /* noop */ }
},
async togglePause() {
@@ -1808,6 +1873,20 @@
// Reset and reload
const grid = state.dom.historyGrid;
const scroller = state.dom.sections || (state.container && state.container.querySelector('.pqueue-sections'));
let restore = null;
if (grid && scroller) {
const rect = grid.getBoundingClientRect();
const scRect = scroller.getBoundingClientRect();
const beforeOffset = rect.top - scRect.top;
const beforeHeight = rect.height;
// Freeze height to avoid layout jump while we clear and load
grid.style.minHeight = `${beforeHeight}px`;
restore = () => {
try { grid.style.minHeight = ''; } catch (err) {}
try { scroller.scrollTop = Math.max(0, scroller.scrollTop + ((grid.getBoundingClientRect().top - scroller.getBoundingClientRect().top) - beforeOffset)); } catch (err) {}
};
}
const sentinel = state.dom.historySentinel;
if (grid) Array.from(grid.querySelectorAll('.pqueue-history-card')).forEach((n) => n.remove());
state.history = [];
@@ -1816,9 +1895,10 @@
state.historyPaging.nextCursor = null;
state.historyPaging.hasMore = true;
if (sentinel) sentinel.style.display = '';
Events.loadMoreHistory();
await Events.loadMoreHistory();
UI.updateHistorySubtitle();
UI.updateSortToggle();
if (typeof restore === 'function') requestAnimationFrame(restore);
} catch (err) {
/* noop */
}
@@ -1960,6 +2040,8 @@
};
async function refresh({ skipIfBusy } = {}) {
// Respect render lock to avoid re-rendering while user is interacting
if (Date.now() < (state.renderLockUntil || 0)) return;
if (state.isRefreshing && skipIfBusy) return;
state.isRefreshing = true;
UI.updateToolbarStatus();