-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathscroll-utils.js
More file actions
195 lines (169 loc) · 5.68 KB
/
Copy pathscroll-utils.js
File metadata and controls
195 lines (169 loc) · 5.68 KB
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
// Scroll utility functions
export function calculateScrollParams({ scrollHeight, windowHeight, duration }) {
const totalScrollDistance = Math.max(0, scrollHeight - windowHeight);
const scrollsPerSecond = 60; // 60fps target
const totalFrames = duration * scrollsPerSecond;
const pixelsPerFrame = totalScrollDistance / totalFrames;
return {
totalScrollDistance,
scrollsPerSecond,
pixelsPerFrame,
};
}
export function createSmoothScrollFunction(totalDistance) {
return (progress) => totalDistance * progress;
}
/**
* Calculate scroll progress with a short ease-in ramp so recordings do not
* lurch into motion on the first frames. The ramp preserves total distance
* over the full duration by slightly increasing the steady-state speed after
* the initial acceleration window.
*/
export function calculateEaseInProgress({
elapsedMs,
totalMs,
easeInMs = Math.min(800, totalMs * 0.15),
}) {
if (totalMs <= 0) {
return 1;
}
const clampedElapsed = Math.max(0, Math.min(elapsedMs, totalMs));
const rampMs = Math.max(0, Math.min(easeInMs, totalMs));
if (rampMs === 0) {
return clampedElapsed / totalMs;
}
const normalizedTravelMs = totalMs - (rampMs / 2);
if (clampedElapsed < rampMs) {
return (clampedElapsed * clampedElapsed) / (2 * rampMs * normalizedTravelMs);
}
return (clampedElapsed - (rampMs / 2)) / normalizedTravelMs;
}
// ID for the style element that overrides scroll-behavior
export const SCROLL_OVERRIDE_ID = 'scrollywood-scroll-override';
/**
* Returns CSS that overrides scroll-behavior: smooth, which conflicts with
* programmatic scrollTo({ behavior: 'instant' }).
* Does NOT override overflow — that's handled conditionally to avoid
* breaking position: sticky in scrollytelling layouts.
*/
export function getScrollBehaviorOverrideCSS() {
return `* { scroll-behavior: auto !important; }`;
}
/**
* Returns CSS that hides visible scrollbars without disabling scrolling.
* This keeps the captured video clean while preserving the page's layout
* and scroll behavior.
*/
export function getScrollbarHideCSS() {
return `
html, body, * {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
background: transparent !important;
}
`;
}
/**
* Returns CSS that forces overflow: auto on html/body.
* Only used when overflow: hidden is detected and prevents scrolling.
*/
export function getOverflowOverrideCSS() {
return `
html, body {
overflow: auto !important;
overflow-y: auto !important;
}
`;
}
// Minimum scroll height threshold - anything below this is considered "not scrollable"
// and we should try the fallback approach
export const MIN_SCROLL_THRESHOLD = 100;
/**
* Calculate the total scrollable height, trying multiple approaches.
* Uses the larger of documentElement or body scrollHeight, minus window height.
* Falls back to a provided maxScroll value if standard calculation yields 0
* or is below the minimum threshold (100px).
*
* @param {Object} metrics - Scroll metrics from the page
* @param {number} metrics.docScrollHeight - document.documentElement.scrollHeight
* @param {number} metrics.bodyScrollHeight - document.body.scrollHeight
* @param {number} metrics.windowHeight - window.innerHeight
* @param {number} [metrics.fallbackMaxScroll] - Optional fallback from actual scroll test
* @returns {number} Total scrollable distance (0 if no scrollable content)
*/
export function calculateTotalScrollHeight({
docScrollHeight,
bodyScrollHeight,
windowHeight,
fallbackMaxScroll = 0,
}) {
const maxScrollHeight = Math.max(docScrollHeight, bodyScrollHeight);
const standardHeight = maxScrollHeight - windowHeight;
// Use standard calculation only if it's above minimum threshold
if (standardHeight >= MIN_SCROLL_THRESHOLD) {
return standardHeight;
}
// Standard calculation too small, use fallback if provided and meaningful
if (fallbackMaxScroll >= MIN_SCROLL_THRESHOLD) {
return fallbackMaxScroll;
}
// Both failed - return whatever we have (could be small positive or 0)
return Math.max(0, standardHeight, fallbackMaxScroll);
}
/**
* Creates a scroll executor that smoothly scrolls over a specified duration.
* Uses setInterval for predictable timing that works with fake timers in tests.
*
* @param {Object} options
* @param {number} options.totalHeight - Total distance to scroll in pixels
* @param {number} options.duration - Duration in seconds
* @param {Function} options.scrollTo - Function to call with scroll position
* @param {Function} options.onScroll - Callback fired on each scroll step
* @param {Function} options.onComplete - Callback fired when scroll completes
* @returns {Object} Executor with start() and stop() methods
*/
export function createScrollExecutor({
totalHeight,
duration,
scrollTo,
onScroll,
onComplete,
}) {
const INTERVAL_MS = 16; // ~60fps
const totalMs = duration * 1000;
let intervalId = null;
let startTime = null;
function tick() {
const elapsed = Date.now() - startTime;
const progress = calculateEaseInProgress({
elapsedMs: elapsed,
totalMs,
});
const position = totalHeight * progress;
scrollTo(position);
onScroll();
if (progress >= 1) {
stop();
onComplete();
}
}
function start() {
startTime = Date.now();
intervalId = setInterval(tick, INTERVAL_MS);
tick(); // Execute immediately
}
function stop() {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
}
return { start, stop };
}