victor HF Staff commited on
Commit
d75ac4c
·
1 Parent(s): fc6549e

Improve snapScrollToBottom with ResizeObserver and smoother scroll

Browse files

Refactors 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.

Files changed (1) hide show
  1. src/lib/actions/snapScrollToBottom.ts +50 -16
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
- // if user scrolled back to within 10px of bottom, we reattach
21
- if (node.scrollTop - (node.scrollHeight - node.clientHeight) >= -detachedOffset) {
 
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
  };