From 50ae9400a107a8535e69c9c1010fcff0e52e2e5f Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 9 Aug 2025 01:37:02 +0200 Subject: [PATCH 1/5] enhance: use textarea as the anchor element if available --- components/preserve-scroll.js | 76 ++++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/components/preserve-scroll.js b/components/preserve-scroll.js index 6a4ab093a2..7152690a9b 100644 --- a/components/preserve-scroll.js +++ b/components/preserve-scroll.js @@ -8,35 +8,65 @@ export default function preserveScroll (callback) { return } - // get a reference element at the center of the viewport to track if content is added above it - const ref = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) - const refTop = ref ? ref.getBoundingClientRect().top + scrollTop : scrollTop + // check if a ref element is in the viewport + const isElementInViewport = (element) => { + if (!element?.getBoundingClientRect) return false + + const rect = element.getBoundingClientRect() + return ( + rect.bottom > 0 && + rect.right > 0 && + rect.top < window.innerHeight && + rect.left < window.innerWidth + ) + } + + // pick a textarea element to use as anchor ref, if any + const selectTextarea = () => { + // pick the focused textarea, if any + const active = document.activeElement + if (active && active.tagName === 'TEXTAREA' && isElementInViewport(active)) { + return active + } + + // if no textarea is focused, check if there are any in the viewport + const textareas = document.querySelectorAll('textarea') + for (const textarea of textareas) { + if (isElementInViewport(textarea)) { + return textarea + } + } + + return null + } + + // if no textarea is found, use the center of the viewport as fallback anchor + const anchorRef = selectTextarea() || document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) + const refTop = anchorRef ? anchorRef.getBoundingClientRect().top + scrollTop : scrollTop // observe the document for changes in height const observer = new window.MutationObserver(() => { - // request animation frame to ensure the DOM is updated + cleanup() + + // double rAF to ensure the DOM is updated - textareas are rendered on the next tick window.requestAnimationFrame(() => { - // we can't proceed if we couldn't find a traceable reference element - if (!ref) { - cleanup() - return - } + window.requestAnimationFrame(() => { + if (!anchorRef) return - // get the new position of the reference element along with the new scroll position - const newRefTop = ref ? ref.getBoundingClientRect().top + window.scrollY : window.scrollY - // has the reference element moved? - const refMoved = newRefTop - refTop - - // if the reference element moved, we need to scroll to the new position - if (refMoved > 0) { - window.scrollTo({ - // some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer - top: scrollTop + Math.ceil(refMoved), - behavior: 'instant' - }) - } + // get the new position of the anchor ref along with the new scroll position + const newRefTop = anchorRef ? anchorRef.getBoundingClientRect().top + window.scrollY : window.scrollY + // has the anchor ref moved? + const refMoved = newRefTop - refTop - cleanup() + // if the anchor ref moved, we need to scroll to the new position + if (refMoved !== 0) { + window.scrollTo({ + // some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer + top: scrollTop + Math.ceil(refMoved), + behavior: 'instant' + }) + } + }) }) }) From 748815474f8ba0934ca6ca1db14fe1ac9344cc86 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 9 Aug 2025 02:31:12 +0200 Subject: [PATCH 2/5] correct only downwards vertical layout shifts --- components/preserve-scroll.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/preserve-scroll.js b/components/preserve-scroll.js index 7152690a9b..c2ed08c3ec 100644 --- a/components/preserve-scroll.js +++ b/components/preserve-scroll.js @@ -59,7 +59,7 @@ export default function preserveScroll (callback) { const refMoved = newRefTop - refTop // if the anchor ref moved, we need to scroll to the new position - if (refMoved !== 0) { + if (refMoved > 0) { window.scrollTo({ // some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer top: scrollTop + Math.ceil(refMoved), From 314f5a45cdcabe02f9e2d2a221ef9cb78b4e76b8 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 10 Aug 2025 02:08:06 +0200 Subject: [PATCH 3/5] remove MutationObserver, use two RAFs to correctly collect the new position to scroll to --- components/preserve-scroll.js | 46 ++++++++++++----------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/components/preserve-scroll.js b/components/preserve-scroll.js index c2ed08c3ec..bc3d1b3508 100644 --- a/components/preserve-scroll.js +++ b/components/preserve-scroll.js @@ -44,40 +44,26 @@ export default function preserveScroll (callback) { const anchorRef = selectTextarea() || document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) const refTop = anchorRef ? anchorRef.getBoundingClientRect().top + scrollTop : scrollTop - // observe the document for changes in height - const observer = new window.MutationObserver(() => { - cleanup() + callback() - // double rAF to ensure the DOM is updated - textareas are rendered on the next tick + // double rAF to ensure the DOM is updated - textareas are rendered on the next tick + window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - if (!anchorRef) return + if (!anchorRef) return - // get the new position of the anchor ref along with the new scroll position - const newRefTop = anchorRef ? anchorRef.getBoundingClientRect().top + window.scrollY : window.scrollY - // has the anchor ref moved? - const refMoved = newRefTop - refTop + // get the new position of the anchor ref along with the new scroll position + const newRefTop = anchorRef ? anchorRef.getBoundingClientRect().top + window.scrollY : window.scrollY + // has the anchor ref moved? + const refMoved = newRefTop - refTop - // if the anchor ref moved, we need to scroll to the new position - if (refMoved > 0) { - window.scrollTo({ - // some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer - top: scrollTop + Math.ceil(refMoved), - behavior: 'instant' - }) - } - }) + // if the anchor ref moved, we need to scroll to the new position + if (refMoved > 0) { + window.scrollTo({ + // some browsers don't respond well to fractional scroll position, so we round up the new position to the nearest integer + top: scrollTop + Math.ceil(refMoved), + behavior: 'instant' + }) + } }) }) - - const timeout = setTimeout(() => cleanup(), 1000) // fallback - - function cleanup () { - clearTimeout(timeout) - observer.disconnect() - } - - observer.observe(document.body, { childList: true, subtree: true }) - - callback() } From 3e412a9bbd0e31443d1015359c40878b43f12cc2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 10 Aug 2025 02:23:50 +0200 Subject: [PATCH 4/5] don't preserve scroll if we are scrolling during the animation --- components/preserve-scroll.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/components/preserve-scroll.js b/components/preserve-scroll.js index bc3d1b3508..52e61433b6 100644 --- a/components/preserve-scroll.js +++ b/components/preserve-scroll.js @@ -51,8 +51,11 @@ export default function preserveScroll (callback) { window.requestAnimationFrame(() => { if (!anchorRef) return + // bail if user scrolled manually + if (window.scrollY !== scrollTop) return + // get the new position of the anchor ref along with the new scroll position - const newRefTop = anchorRef ? anchorRef.getBoundingClientRect().top + window.scrollY : window.scrollY + const newRefTop = anchorRef.getBoundingClientRect().top + window.scrollY // has the anchor ref moved? const refMoved = newRefTop - refTop From c582e0bc43908bfea374cf5609372abcb8ef2a81 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 10 Aug 2025 12:56:34 +0200 Subject: [PATCH 5/5] preserve scroll even if we're at the top, textbox/center ref has priority --- components/preserve-scroll.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/components/preserve-scroll.js b/components/preserve-scroll.js index 52e61433b6..aeea87e7a5 100644 --- a/components/preserve-scroll.js +++ b/components/preserve-scroll.js @@ -2,12 +2,6 @@ export default function preserveScroll (callback) { // preserve the actual scroll position const scrollTop = window.scrollY - // if the scroll position is at the top, we don't need to preserve it, just call the callback - if (scrollTop <= 0) { - callback() - return - } - // check if a ref element is in the viewport const isElementInViewport = (element) => { if (!element?.getBoundingClientRect) return false @@ -21,7 +15,7 @@ export default function preserveScroll (callback) { ) } - // pick a textarea element to use as anchor ref, if any + // pick a textarea element to use as anchor ref const selectTextarea = () => { // pick the focused textarea, if any const active = document.activeElement