Improve snapScrollToBottom with ResizeObserver and smoother scroll
Browse filesRefactors the snapScrollToBottom action to use ResizeObserver for more reliable scroll-to-bottom behavior on content changes, and enhances scroll settling by waiting for animation frames to handle late layout shifts. Also improves detachment/reattachment logic and ensures cleanup of observers.
src/lib/actions/snapScrollToBottom.ts
CHANGED
|
@@ -3,6 +3,13 @@ import { tick } from "svelte";
|
|
| 3 |
|
| 4 |
const detachedOffset = 10;
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
/**
|
| 7 |
* @param node element to snap scroll to bottom
|
| 8 |
* @param dependency pass in a dependency to update scroll on changes.
|
|
@@ -10,6 +17,32 @@ const detachedOffset = 10;
|
|
| 10 |
export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
|
| 11 |
let prevScrollValue = node.scrollTop;
|
| 12 |
let isDetached = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
const handleScroll = () => {
|
| 15 |
// if user scrolled up, we detach
|
|
@@ -17,37 +50,38 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
|
|
| 17 |
isDetached = true;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
| 21 |
-
if (
|
|
|
|
| 22 |
isDetached = false;
|
|
|
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
prevScrollValue = node.scrollTop;
|
| 26 |
};
|
| 27 |
|
| 28 |
-
const updateScroll = async (_options: { force?: boolean } = {}) => {
|
| 29 |
-
const defaultOptions = { force: false };
|
| 30 |
-
const options = { ...defaultOptions, ..._options };
|
| 31 |
-
const { force } = options;
|
| 32 |
-
|
| 33 |
-
if (!force && isDetached && !navigating.to) return;
|
| 34 |
-
|
| 35 |
-
// wait for next tick to ensure that the DOM is updated
|
| 36 |
-
await tick();
|
| 37 |
-
|
| 38 |
-
node.scrollTo({ top: node.scrollHeight });
|
| 39 |
-
};
|
| 40 |
-
|
| 41 |
node.addEventListener("scroll", handleScroll);
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
if (dependency) {
|
| 44 |
-
updateScroll({ force: true });
|
| 45 |
}
|
| 46 |
|
| 47 |
return {
|
| 48 |
update: updateScroll,
|
| 49 |
destroy: () => {
|
| 50 |
node.removeEventListener("scroll", handleScroll);
|
|
|
|
| 51 |
},
|
| 52 |
};
|
| 53 |
};
|
|
|
|
| 3 |
|
| 4 |
const detachedOffset = 10;
|
| 5 |
|
| 6 |
+
const waitForAnimationFrame = () =>
|
| 7 |
+
typeof requestAnimationFrame === "function"
|
| 8 |
+
? new Promise<void>((resolve) => {
|
| 9 |
+
requestAnimationFrame(() => resolve());
|
| 10 |
+
})
|
| 11 |
+
: Promise.resolve();
|
| 12 |
+
|
| 13 |
/**
|
| 14 |
* @param node element to snap scroll to bottom
|
| 15 |
* @param dependency pass in a dependency to update scroll on changes.
|
|
|
|
| 17 |
export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
|
| 18 |
let prevScrollValue = node.scrollTop;
|
| 19 |
let isDetached = false;
|
| 20 |
+
let resizeObserver: ResizeObserver | undefined;
|
| 21 |
+
|
| 22 |
+
const scrollToBottom = () => {
|
| 23 |
+
node.scrollTo({ top: node.scrollHeight });
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;
|
| 27 |
+
|
| 28 |
+
async function updateScroll(_options: { force?: boolean } = {}) {
|
| 29 |
+
const options = { force: false, ..._options };
|
| 30 |
+
const { force } = options;
|
| 31 |
+
|
| 32 |
+
if (!force && isDetached && !navigating.to) return;
|
| 33 |
+
|
| 34 |
+
// wait for the next tick to ensure that the DOM is updated
|
| 35 |
+
await tick();
|
| 36 |
+
scrollToBottom();
|
| 37 |
+
|
| 38 |
+
// ensure we settle after late layout shifts (e.g. markdown/image renders)
|
| 39 |
+
if (typeof requestAnimationFrame === "function") {
|
| 40 |
+
await waitForAnimationFrame();
|
| 41 |
+
scrollToBottom();
|
| 42 |
+
await waitForAnimationFrame();
|
| 43 |
+
scrollToBottom();
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
|
| 47 |
const handleScroll = () => {
|
| 48 |
// if user scrolled up, we detach
|
|
|
|
| 50 |
isDetached = true;
|
| 51 |
}
|
| 52 |
|
| 53 |
+
const atBottom = distanceFromBottom() <= detachedOffset;
|
| 54 |
+
if (atBottom) {
|
| 55 |
+
const wasDetached = isDetached;
|
| 56 |
isDetached = false;
|
| 57 |
+
if (wasDetached) {
|
| 58 |
+
void updateScroll({ force: true });
|
| 59 |
+
}
|
| 60 |
}
|
| 61 |
|
| 62 |
prevScrollValue = node.scrollTop;
|
| 63 |
};
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
node.addEventListener("scroll", handleScroll);
|
| 66 |
|
| 67 |
+
if (typeof ResizeObserver !== "undefined") {
|
| 68 |
+
const target = node.firstElementChild ?? node;
|
| 69 |
+
resizeObserver = new ResizeObserver(() => {
|
| 70 |
+
if (isDetached && !navigating.to) return;
|
| 71 |
+
scrollToBottom();
|
| 72 |
+
});
|
| 73 |
+
resizeObserver.observe(target);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
if (dependency) {
|
| 77 |
+
void updateScroll({ force: true });
|
| 78 |
}
|
| 79 |
|
| 80 |
return {
|
| 81 |
update: updateScroll,
|
| 82 |
destroy: () => {
|
| 83 |
node.removeEventListener("scroll", handleScroll);
|
| 84 |
+
resizeObserver?.disconnect();
|
| 85 |
},
|
| 86 |
};
|
| 87 |
};
|