File size: 10,057 Bytes
4331e77
8025183
89fa02a
2cab13e
 
cb00da9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d75ac4c
34ff433
2cab13e
 
cb00da9
 
2cab13e
 
 
34ff433
cb00da9
 
34ff433
cb00da9
 
 
2cab13e
34ff433
cb00da9
 
 
 
2cab13e
 
 
cb00da9
2cab13e
 
 
 
cb00da9
 
 
 
 
 
 
d75ac4c
2cab13e
 
 
cb00da9
 
 
 
 
 
 
 
 
2cab13e
 
d75ac4c
cb00da9
 
 
 
d75ac4c
2cab13e
 
cb00da9
d75ac4c
cb00da9
2cab13e
 
 
 
 
 
 
d75ac4c
 
cb00da9
 
d75ac4c
cb00da9
d75ac4c
cb00da9
 
 
d75ac4c
34ff433
cb00da9
 
 
34ff433
cb00da9
34ff433
cb00da9
2cab13e
cb00da9
 
2cab13e
 
cb00da9
2cab13e
 
 
 
cb00da9
2cab13e
 
 
d75ac4c
cb00da9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cab13e
 
 
cb00da9
 
2cab13e
 
cb00da9
 
2cab13e
 
 
cb00da9
2cab13e
34ff433
 
cb00da9
 
 
2cab13e
cb00da9
 
 
 
 
2cab13e
cb00da9
2cab13e
34ff433
 
2cab13e
 
cb00da9
 
 
 
2cab13e
 
 
 
cb00da9
2cab13e
 
 
 
cb00da9
2cab13e
 
 
 
 
 
 
 
 
 
 
 
cb00da9
 
2cab13e
 
 
 
 
 
cb00da9
 
d75ac4c
2cab13e
9e03f04
cb00da9
 
 
 
9e03f04
34ff433
cb00da9
 
34ff433
 
 
cb00da9
 
2cab13e
 
 
1d04bf7
cb00da9
d75ac4c
2cab13e
 
1d04bf7
34ff433
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
import { navigating } from "$app/state";
import { tick } from "svelte";

// Threshold to determine if user is "at bottom" - larger value prevents false detachment
const BOTTOM_THRESHOLD = 50;
const USER_SCROLL_DEBOUNCE_MS = 150;
const PROGRAMMATIC_SCROLL_GRACE_MS = 100;
const TOUCH_DETACH_THRESHOLD_PX = 10;

interface ScrollDependency {
	signal: unknown;
	forceReattach?: number;
}

type MaybeScrollDependency = ScrollDependency | unknown;

const getForceReattach = (value: MaybeScrollDependency): number => {
	if (typeof value === "object" && value !== null && "forceReattach" in value) {
		return (value as ScrollDependency).forceReattach ?? 0;
	}
	return 0;
};

/**
 * Auto-scroll action that snaps to bottom while respecting user scroll intent.
 *
 * Key behaviors:
 * 1. Uses wheel/touch events to detect actual user intent
 * 2. Uses IntersectionObserver on a sentinel element to reliably detect "at bottom" state
 * 3. Larger threshold to prevent edge-case false detachments
 *
 * @param node element to snap scroll to bottom
 * @param dependency pass in { signal, forceReattach } - signal triggers scroll updates,
 *                   forceReattach (counter) forces re-attachment when incremented
 */
export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDependency) => {
	// --- State ----------------------------------------------------------------

	// Track whether user has intentionally scrolled away from bottom
	let isDetached = false;

	// Track the last forceReattach value to detect changes
	let lastForceReattach = getForceReattach(dependency);

	// Track if user is actively scrolling (via wheel/touch)
	let userScrolling = false;
	let userScrollTimeout: ReturnType<typeof setTimeout> | undefined;

	// Track programmatic scrolls to avoid treating them as user scrolls
	let isProgrammaticScroll = false;
	let lastProgrammaticScrollTime = 0;

	// Track previous scroll position to detect scrollbar drags
	let prevScrollTop = node.scrollTop;

	// Touch handling state
	let touchStartY = 0;

	// Observers and sentinel
	let resizeObserver: ResizeObserver | undefined;
	let intersectionObserver: IntersectionObserver | undefined;
	let sentinel: HTMLDivElement | undefined;

	// Track content height for early-return optimization during streaming
	let lastScrollHeight = node.scrollHeight;

	// --- Helpers --------------------------------------------------------------

	const clearUserScrollTimeout = () => {
		if (userScrollTimeout) {
			clearTimeout(userScrollTimeout);
			userScrollTimeout = undefined;
		}
	};

	const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;

	const isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD;

	const scrollToBottom = () => {
		isProgrammaticScroll = true;
		lastProgrammaticScrollTime = Date.now();

		node.scrollTo({ top: node.scrollHeight });

		if (typeof requestAnimationFrame === "function") {
			requestAnimationFrame(() => {
				isProgrammaticScroll = false;
			});
		} else {
			isProgrammaticScroll = false;
		}
	};

	const settleScrollAfterLayout = async () => {
		if (typeof requestAnimationFrame !== "function") return;

		const raf = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));

		await raf();
		if (!userScrolling && !isDetached) {
			scrollToBottom();
		}

		await raf();
		if (!userScrolling && !isDetached) {
			scrollToBottom();
		}
	};

	const scheduleUserScrollEndCheck = () => {
		userScrolling = true;
		clearUserScrollTimeout();

		userScrollTimeout = setTimeout(() => {
			userScrolling = false;

			// If user scrolled back to bottom, re-attach
			if (isAtBottom()) {
				isDetached = false;
			}

			// Re-trigger scroll if still attached, to catch content that arrived during scrolling
			if (!isDetached) {
				scrollToBottom();
			}
		}, USER_SCROLL_DEBOUNCE_MS);
	};

	const createSentinel = () => {
		sentinel = document.createElement("div");
		sentinel.style.height = "1px";
		sentinel.style.width = "100%";
		sentinel.setAttribute("aria-hidden", "true");
		sentinel.setAttribute("data-scroll-sentinel", "");

		// Find the content container (first child) and append sentinel there
		const container = node.firstElementChild;
		if (container) {
			container.appendChild(sentinel);
		} else {
			node.appendChild(sentinel);
		}
	};

	const setupIntersectionObserver = () => {
		if (typeof IntersectionObserver === "undefined" || !sentinel) return;

		intersectionObserver = new IntersectionObserver(
			(entries) => {
				const entry = entries[0];

				// If sentinel is visible and user isn't actively scrolling, we're at bottom
				if (entry?.isIntersecting && !userScrolling) {
					isDetached = false;
					// Immediately scroll to catch up with any content that arrived while detached
					scrollToBottom();
				}
			},
			{
				root: node,
				threshold: 0,
				rootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`,
			}
		);

		intersectionObserver.observe(sentinel);
	};

	const setupResizeObserver = () => {
		if (typeof ResizeObserver === "undefined") return;

		const target = node.firstElementChild ?? node;
		resizeObserver = new ResizeObserver(() => {
			// Don't auto-scroll if user has detached and we're not navigating
			if (isDetached && !navigating.to) return;
			// Don't interrupt active user scrolling
			if (userScrolling) return;

			scrollToBottom();
		});

		resizeObserver.observe(target);
	};

	// --- Action update logic --------------------------------------------------

	const handleForceReattach = async (newDependency: MaybeScrollDependency) => {
		const forceReattach = getForceReattach(newDependency);

		if (forceReattach > lastForceReattach) {
			lastForceReattach = forceReattach;
			isDetached = false;
			userScrolling = false;
			clearUserScrollTimeout();

			await tick();
			scrollToBottom();
			return true;
		}

		return false;
	};

	async function updateScroll(newDependency?: MaybeScrollDependency) {
		// 1. Explicit force re-attach
		if (newDependency && (await handleForceReattach(newDependency))) {
			return;
		}

		// 2. Don't scroll if user has detached and we're not navigating
		if (isDetached && !navigating.to) return;

		// 3. Don't scroll if user is actively scrolling
		if (userScrolling) return;

		// 4. Early return if already at bottom and no content change (perf optimization for streaming)
		const currentHeight = node.scrollHeight;
		if (isAtBottom() && currentHeight === lastScrollHeight) {
			return;
		}
		lastScrollHeight = currentHeight;

		// 5. Wait for DOM to update, then scroll and settle after layout shifts
		await tick();
		scrollToBottom();
		await settleScrollAfterLayout();
	}

	// --- Event handlers -------------------------------------------------------

	// Detect user scroll intent via wheel events (mouse/trackpad)
	const handleWheel = (event: WheelEvent) => {
		const { deltaY } = event;

		// User is scrolling up - detach
		if (deltaY < 0) {
			isDetached = true;
		}

		// User is scrolling down - check for re-attachment immediately
		// This ensures fast re-attachment when user scrolls to bottom during fast generation
		if (deltaY > 0 && isAtBottom()) {
			isDetached = false;
			userScrolling = false;
			clearUserScrollTimeout();
			scrollToBottom();
			return;
		}

		scheduleUserScrollEndCheck();
	};

	// Detect user scroll intent via touch events (mobile)
	const handleTouchStart = (event: TouchEvent) => {
		touchStartY = event.touches[0]?.clientY ?? 0;
	};

	const handleTouchMove = (event: TouchEvent) => {
		const touchY = event.touches[0]?.clientY ?? 0;
		const deltaY = touchStartY - touchY;

		// User is scrolling up (finger moving down)
		if (deltaY < -TOUCH_DETACH_THRESHOLD_PX) {
			isDetached = true;
		}

		// User is scrolling down (finger moving up) - check for re-attachment immediately
		if (deltaY > TOUCH_DETACH_THRESHOLD_PX && isAtBottom()) {
			isDetached = false;
			userScrolling = false;
			clearUserScrollTimeout();
			scrollToBottom();
			touchStartY = touchY;
			return;
		}

		scheduleUserScrollEndCheck();
		touchStartY = touchY;
	};

	// Handle scroll events to detect scrollbar usage and re-attach when at bottom
	const handleScroll = () => {
		const now = Date.now();
		const timeSinceLastProgrammaticScroll = now - lastProgrammaticScrollTime;
		const inGracePeriod =
			isProgrammaticScroll || timeSinceLastProgrammaticScroll < PROGRAMMATIC_SCROLL_GRACE_MS;

		// If not from wheel/touch, this is likely a scrollbar drag
		if (!userScrolling) {
			const scrollingUp = node.scrollTop < prevScrollTop;

			// Always allow detach (scrolling up) - don't ignore user intent
			if (scrollingUp) {
				isDetached = true;
			}

			// Only re-attach when at bottom if NOT in grace period
			// (avoids false re-attach from content resize pushing scroll position)
			if (!inGracePeriod && isAtBottom()) {
				isDetached = false;
				// Immediately scroll to catch up with any content that arrived while detached
				scrollToBottom();
			}
		}

		prevScrollTop = node.scrollTop;
	};

	// --- Setup ----------------------------------------------------------------

	node.addEventListener("wheel", handleWheel, { passive: true });
	node.addEventListener("touchstart", handleTouchStart, { passive: true });
	node.addEventListener("touchmove", handleTouchMove, { passive: true });
	node.addEventListener("scroll", handleScroll, { passive: true });

	createSentinel();
	setupIntersectionObserver();
	setupResizeObserver();

	// Initial scroll if we have content
	if (dependency) {
		void (async () => {
			await tick();
			scrollToBottom();
		})();
	}

	// --- Cleanup --------------------------------------------------------------

	return {
		update: updateScroll,
		destroy: () => {
			clearUserScrollTimeout();

			node.removeEventListener("wheel", handleWheel);
			node.removeEventListener("touchstart", handleTouchStart);
			node.removeEventListener("touchmove", handleTouchMove);
			node.removeEventListener("scroll", handleScroll);

			resizeObserver?.disconnect();
			intersectionObserver?.disconnect();
			sentinel?.remove();
		},
	};
};