mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 16:46:31 +03:00
fix(live): observer-based scroll lock holds element at viewport top during session
The reload-only fix missed the primary case: Bun's HTML loader hot-patches the DOM in place rather than doing a full page load, so the resume codepath never ran and the browser's scroll drifted wherever Bun's patch left it. Likewise variant cycling (taller → shorter) and agent-driven variant inserts both mutate layout without a reload. Add a scroll lock: on Go (and on resume after a true reload), capture the selected element's viewport-top and install a MutationObserver on body that re-measures the target and corrects scroll on every batch of DOM mutations. The target is re-resolved each pass via sessionId + visible variant, so it survives DOM swaps that invalidate `selectedElement`. Scroll intent events (wheel, touchstart, arrow/page keys) re-anchor to the new position so we never fight a user who scrolls during a session. Also set `scrollRestoration = 'manual'` at init so true reloads don't land the user somewhere odd before our correction runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4f4df85250
commit
ad17880af1
@@ -91,6 +91,14 @@
|
||||
let selectedAction = 'impeccable';
|
||||
let selectedCount = 3;
|
||||
|
||||
// Scroll lock — holds the selected element at a fixed viewport-top while
|
||||
// the session is active, so HMR DOM patches and variant swaps don't drift
|
||||
// the page. See startScrollLock / stopScrollLock below.
|
||||
let scrollLockObserver = null;
|
||||
let scrollLockTargetTop = null;
|
||||
let scrollLockRaf = null;
|
||||
let scrollLockAbort = null;
|
||||
|
||||
// UI refs
|
||||
let highlightEl = null;
|
||||
let tooltipEl = null;
|
||||
@@ -1312,6 +1320,78 @@
|
||||
return variantDiv;
|
||||
}
|
||||
|
||||
// Resolve the element whose top we want to lock: the currently-visible
|
||||
// variant's content (falling back to the original), identified by
|
||||
// sessionId so we survive DOM swaps that invalidate `selectedElement`.
|
||||
function resolveScrollLockTarget(sessionId) {
|
||||
const wrapper = sessionId
|
||||
? document.querySelector('[data-impeccable-variants="' + sessionId + '"]')
|
||||
: null;
|
||||
if (wrapper) {
|
||||
const idx = visibleVariant > 0 ? visibleVariant : 'original';
|
||||
const el = pickVariantContent(wrapper, idx);
|
||||
if (el) return el;
|
||||
}
|
||||
return selectedElement?.isConnected ? selectedElement : null;
|
||||
}
|
||||
|
||||
// Hold the resolved target at a fixed viewport-top across DOM mutations
|
||||
// (HMR patches, variant inserts, variant cycle swaps). If the caller
|
||||
// passes `initialTargetTop`, use it (e.g. on resume after full reload);
|
||||
// otherwise capture the current target's top.
|
||||
function startScrollLock(sessionId, initialTargetTop) {
|
||||
stopScrollLock();
|
||||
const initial = resolveScrollLockTarget(sessionId);
|
||||
if (!initial) return;
|
||||
scrollLockTargetTop = typeof initialTargetTop === 'number' && isFinite(initialTargetTop)
|
||||
? initialTargetTop
|
||||
: initial.getBoundingClientRect().top;
|
||||
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
|
||||
const correct = () => {
|
||||
scrollLockRaf = null;
|
||||
if (scrollLockTargetTop == null) return;
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (!el) return;
|
||||
const delta = el.getBoundingClientRect().top - scrollLockTargetTop;
|
||||
if (Math.abs(delta) > 0.5) {
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (scrollLockRaf != null) return;
|
||||
scrollLockRaf = requestAnimationFrame(correct);
|
||||
};
|
||||
|
||||
scrollLockObserver = new MutationObserver(schedule);
|
||||
scrollLockObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Treat explicit user scroll intent as a re-anchor: update the target
|
||||
// top to wherever the element is now, so we don't fight the user.
|
||||
scrollLockAbort = new AbortController();
|
||||
const sig = { signal: scrollLockAbort.signal };
|
||||
const reanchor = () => {
|
||||
const el = resolveScrollLockTarget(sessionId);
|
||||
if (el) scrollLockTargetTop = el.getBoundingClientRect().top;
|
||||
};
|
||||
window.addEventListener('wheel', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('touchstart', reanchor, { passive: true, ...sig });
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) reanchor();
|
||||
}, sig);
|
||||
|
||||
schedule();
|
||||
if (document.fonts?.ready) document.fonts.ready.then(schedule).catch(() => {});
|
||||
}
|
||||
|
||||
function stopScrollLock() {
|
||||
if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
|
||||
if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
|
||||
if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
|
||||
scrollLockTargetTop = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MutationObserver for progressive variant reveal
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1487,6 +1567,7 @@
|
||||
hideAnnotOverlay();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
@@ -1674,6 +1755,7 @@
|
||||
saveSession();
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
startScrollLock(currentSessionId);
|
||||
|
||||
captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
|
||||
}
|
||||
@@ -2097,6 +2179,7 @@ void main() {
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
@@ -2219,6 +2302,7 @@ void main() {
|
||||
hideHighlight();
|
||||
stopScrollTracking();
|
||||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||||
stopScrollLock();
|
||||
clearSession();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
@@ -2298,25 +2382,6 @@ void main() {
|
||||
// Set display state BEFORE starting observer (avoid triggering it)
|
||||
if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
|
||||
|
||||
// Restore scroll: land the selected element back at the same
|
||||
// viewport-relative top it had before the reload. Two passes — once
|
||||
// synchronously, once after fonts/images settle — catch late layout
|
||||
// shifts without animating the correction.
|
||||
if (saved?.scrollAnchor && selectedElement) {
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
const targetTop = saved.scrollAnchor.viewportTop;
|
||||
const correct = () => {
|
||||
if (!selectedElement?.isConnected) return;
|
||||
const delta = selectedElement.getBoundingClientRect().top - targetTop;
|
||||
if (Math.abs(delta) > 0.5) window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
};
|
||||
correct();
|
||||
requestAnimationFrame(() => requestAnimationFrame(correct));
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.ready.then(() => requestAnimationFrame(correct)).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||||
startScrollTracking();
|
||||
@@ -2326,6 +2391,10 @@ void main() {
|
||||
if (variantObserver) variantObserver.disconnect();
|
||||
variantObserver = startVariantObserver(currentSessionId);
|
||||
|
||||
// Hold the target at its saved viewport top through any subsequent
|
||||
// HMR patches, variant inserts, or cycle swaps.
|
||||
startScrollLock(currentSessionId, saved?.scrollAnchor?.viewportTop);
|
||||
|
||||
// If we reloaded mid-generation (Bun's HTML HMR destroys the shader
|
||||
// canvas), re-capture the original's content and restart the shader so
|
||||
// the wait doesn't go dead.
|
||||
@@ -3775,6 +3844,7 @@ void main() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
try { history.scrollRestoration = 'manual'; } catch {}
|
||||
initHighlight();
|
||||
initAnnotOverlay();
|
||||
initBar();
|
||||
|
||||
Reference in New Issue
Block a user