domet 1.0.6 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -68
- package/dist/cjs/index.d.ts +84 -24
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +805 -290
- package/dist/es/index.d.mts +84 -24
- package/dist/es/index.d.mts.map +1 -0
- package/dist/es/index.mjs +806 -292
- package/package.json +13 -3
package/dist/cjs/index.js
CHANGED
|
@@ -1,42 +1,412 @@
|
|
|
1
|
+
'use client';
|
|
1
2
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
2
3
|
|
|
3
4
|
var react = require('react');
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
6
|
+
const DEFAULT_THRESHOLD = 0.6;
|
|
7
|
+
const DEFAULT_HYSTERESIS = 150;
|
|
8
|
+
const DEFAULT_OFFSET = 0;
|
|
9
|
+
const DEFAULT_THROTTLE = 10;
|
|
7
10
|
const SCROLL_IDLE_MS = 100;
|
|
11
|
+
const MIN_SCROLL_THRESHOLD = 10;
|
|
12
|
+
const EDGE_TOLERANCE = 5;
|
|
13
|
+
|
|
14
|
+
const PERCENT_REGEX$1 = /^(-?\d+(?:\.\d+)?)%$/;
|
|
15
|
+
const VALIDATION_LIMITS = {
|
|
16
|
+
offset: {
|
|
17
|
+
min: -1e4,
|
|
18
|
+
max: 10000
|
|
19
|
+
},
|
|
20
|
+
offsetPercent: {
|
|
21
|
+
min: -500,
|
|
22
|
+
max: 500
|
|
23
|
+
},
|
|
24
|
+
threshold: {
|
|
25
|
+
min: 0,
|
|
26
|
+
max: 1
|
|
27
|
+
},
|
|
28
|
+
hysteresis: {
|
|
29
|
+
min: 0,
|
|
30
|
+
max: 1000
|
|
31
|
+
},
|
|
32
|
+
throttle: {
|
|
33
|
+
min: 0,
|
|
34
|
+
max: 1000
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
function warn(message) {
|
|
38
|
+
if (process.env.NODE_ENV !== "production") {
|
|
39
|
+
console.warn(`[domet] ${message}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isFiniteNumber(value) {
|
|
43
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
44
|
+
}
|
|
45
|
+
function clamp(value, min, max) {
|
|
46
|
+
return Math.max(min, Math.min(max, value));
|
|
47
|
+
}
|
|
48
|
+
function sanitizeOffset(offset) {
|
|
49
|
+
if (offset === undefined) {
|
|
50
|
+
return DEFAULT_OFFSET;
|
|
51
|
+
}
|
|
52
|
+
if (typeof offset === "number") {
|
|
53
|
+
if (!isFiniteNumber(offset)) {
|
|
54
|
+
warn(`Invalid offset value: ${offset}. Using default.`);
|
|
55
|
+
return DEFAULT_OFFSET;
|
|
56
|
+
}
|
|
57
|
+
const { min, max } = VALIDATION_LIMITS.offset;
|
|
58
|
+
if (offset < min || offset > max) {
|
|
59
|
+
warn(`Offset ${offset} clamped to [${min}, ${max}].`);
|
|
60
|
+
return clamp(offset, min, max);
|
|
61
|
+
}
|
|
62
|
+
return offset;
|
|
63
|
+
}
|
|
64
|
+
if (typeof offset === "string") {
|
|
65
|
+
const trimmed = offset.trim();
|
|
66
|
+
const match = PERCENT_REGEX$1.exec(trimmed);
|
|
67
|
+
if (!match) {
|
|
68
|
+
warn(`Invalid offset format: "${offset}". Using default.`);
|
|
69
|
+
return DEFAULT_OFFSET;
|
|
70
|
+
}
|
|
71
|
+
const percent = parseFloat(match[1]);
|
|
72
|
+
if (!isFiniteNumber(percent)) {
|
|
73
|
+
warn(`Invalid percentage value in offset: "${offset}". Using default.`);
|
|
74
|
+
return DEFAULT_OFFSET;
|
|
75
|
+
}
|
|
76
|
+
const { min, max } = VALIDATION_LIMITS.offsetPercent;
|
|
77
|
+
if (percent < min || percent > max) {
|
|
78
|
+
warn(`Offset percentage ${percent}% clamped to [${min}%, ${max}%].`);
|
|
79
|
+
return `${clamp(percent, min, max)}%`;
|
|
80
|
+
}
|
|
81
|
+
return trimmed;
|
|
82
|
+
}
|
|
83
|
+
warn(`Invalid offset type: ${typeof offset}. Using default.`);
|
|
84
|
+
return DEFAULT_OFFSET;
|
|
85
|
+
}
|
|
86
|
+
function sanitizeThreshold(threshold) {
|
|
87
|
+
if (threshold === undefined) {
|
|
88
|
+
return DEFAULT_THRESHOLD;
|
|
89
|
+
}
|
|
90
|
+
if (!isFiniteNumber(threshold)) {
|
|
91
|
+
warn(`Invalid threshold value: ${threshold}. Using default.`);
|
|
92
|
+
return DEFAULT_THRESHOLD;
|
|
93
|
+
}
|
|
94
|
+
const { min, max } = VALIDATION_LIMITS.threshold;
|
|
95
|
+
if (threshold < min || threshold > max) {
|
|
96
|
+
warn(`Threshold ${threshold} clamped to [${min}, ${max}].`);
|
|
97
|
+
return clamp(threshold, min, max);
|
|
98
|
+
}
|
|
99
|
+
return threshold;
|
|
100
|
+
}
|
|
101
|
+
function sanitizeHysteresis(hysteresis) {
|
|
102
|
+
if (hysteresis === undefined) {
|
|
103
|
+
return DEFAULT_HYSTERESIS;
|
|
104
|
+
}
|
|
105
|
+
if (!isFiniteNumber(hysteresis)) {
|
|
106
|
+
warn(`Invalid hysteresis value: ${hysteresis}. Using default.`);
|
|
107
|
+
return DEFAULT_HYSTERESIS;
|
|
108
|
+
}
|
|
109
|
+
const { min, max } = VALIDATION_LIMITS.hysteresis;
|
|
110
|
+
if (hysteresis < min || hysteresis > max) {
|
|
111
|
+
warn(`Hysteresis ${hysteresis} clamped to [${min}, ${max}].`);
|
|
112
|
+
return clamp(hysteresis, min, max);
|
|
113
|
+
}
|
|
114
|
+
return hysteresis;
|
|
115
|
+
}
|
|
116
|
+
function sanitizeThrottle(throttle) {
|
|
117
|
+
if (throttle === undefined) {
|
|
118
|
+
return DEFAULT_THROTTLE;
|
|
119
|
+
}
|
|
120
|
+
if (!isFiniteNumber(throttle)) {
|
|
121
|
+
warn(`Invalid throttle value: ${throttle}. Using default.`);
|
|
122
|
+
return DEFAULT_THROTTLE;
|
|
123
|
+
}
|
|
124
|
+
const { min, max } = VALIDATION_LIMITS.throttle;
|
|
125
|
+
if (throttle < min || throttle > max) {
|
|
126
|
+
warn(`Throttle ${throttle} clamped to [${min}, ${max}].`);
|
|
127
|
+
return clamp(throttle, min, max);
|
|
128
|
+
}
|
|
129
|
+
return throttle;
|
|
130
|
+
}
|
|
131
|
+
function sanitizeIds(ids) {
|
|
132
|
+
if (!ids || !Array.isArray(ids)) {
|
|
133
|
+
warn("Invalid ids: expected an array. Using empty array.");
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
const sanitized = [];
|
|
138
|
+
for (const id of ids){
|
|
139
|
+
if (typeof id !== "string") {
|
|
140
|
+
warn(`Invalid id type: ${typeof id}. Skipping.`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const trimmed = id.trim();
|
|
144
|
+
if (trimmed === "") {
|
|
145
|
+
warn("Empty string id detected. Skipping.");
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (seen.has(trimmed)) {
|
|
149
|
+
warn(`Duplicate id "${trimmed}" detected. Skipping.`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
seen.add(trimmed);
|
|
153
|
+
sanitized.push(trimmed);
|
|
154
|
+
}
|
|
155
|
+
return sanitized;
|
|
156
|
+
}
|
|
157
|
+
function sanitizeSelector(selector) {
|
|
158
|
+
if (selector === undefined) {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
if (typeof selector !== "string") {
|
|
162
|
+
warn(`Invalid selector type: ${typeof selector}. Using empty string.`);
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
const trimmed = selector.trim();
|
|
166
|
+
if (trimmed === "") {
|
|
167
|
+
warn("Empty selector provided.");
|
|
168
|
+
}
|
|
169
|
+
return trimmed;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const PERCENT_REGEX = /^(-?\d+(?:\.\d+)?)%$/;
|
|
173
|
+
function resolveContainer(input) {
|
|
174
|
+
if (typeof window === "undefined") return null;
|
|
175
|
+
if (input === undefined) return null;
|
|
176
|
+
return input.current;
|
|
177
|
+
}
|
|
178
|
+
function resolveSectionsFromIds(ids, refs) {
|
|
179
|
+
return ids.map((id)=>({
|
|
180
|
+
id,
|
|
181
|
+
element: refs[id]
|
|
182
|
+
})).filter((s)=>s.element != null);
|
|
183
|
+
}
|
|
184
|
+
function resolveSectionsFromSelector(selector) {
|
|
185
|
+
if (typeof window === "undefined") return [];
|
|
186
|
+
try {
|
|
187
|
+
const elements = document.querySelectorAll(selector);
|
|
188
|
+
return Array.from(elements).map((el, index)=>({
|
|
189
|
+
id: el.id || el.dataset.domet || `section-${index}`,
|
|
190
|
+
element: el
|
|
191
|
+
}));
|
|
192
|
+
} catch {
|
|
193
|
+
if (process.env.NODE_ENV !== "production") {
|
|
194
|
+
console.warn(`[domet] Invalid CSS selector: "${selector}"`);
|
|
195
|
+
}
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function resolveOffset(offset, viewportHeight, defaultOffset) {
|
|
200
|
+
const value = offset ?? defaultOffset;
|
|
201
|
+
const safeViewportHeight = Number.isFinite(viewportHeight) ? viewportHeight : 0;
|
|
202
|
+
if (typeof value === "number") {
|
|
203
|
+
return Number.isFinite(value) ? value : 0;
|
|
204
|
+
}
|
|
205
|
+
const match = PERCENT_REGEX.exec(value);
|
|
206
|
+
if (match) {
|
|
207
|
+
const percent = parseFloat(match[1]);
|
|
208
|
+
if (!Number.isFinite(percent)) return 0;
|
|
209
|
+
return percent / 100 * safeViewportHeight;
|
|
210
|
+
}
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getSectionBounds(sections, container) {
|
|
215
|
+
const scrollTop = container ? container.scrollTop : window.scrollY;
|
|
216
|
+
const containerTop = container ? container.getBoundingClientRect().top : 0;
|
|
217
|
+
return sections.map(({ id, element })=>{
|
|
218
|
+
const rect = element.getBoundingClientRect();
|
|
219
|
+
const relativeTop = container ? rect.top - containerTop + scrollTop : rect.top + window.scrollY;
|
|
220
|
+
return {
|
|
221
|
+
id,
|
|
222
|
+
top: relativeTop,
|
|
223
|
+
bottom: relativeTop + rect.height,
|
|
224
|
+
height: rect.height
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
function calculateSectionScores(sectionBounds, sections, ctx) {
|
|
229
|
+
const { scrollY, viewportHeight, effectiveOffset, visibilityThreshold} = ctx;
|
|
230
|
+
const viewportTop = scrollY;
|
|
231
|
+
const viewportBottom = scrollY + viewportHeight;
|
|
232
|
+
const maxScroll = Math.max(1, ctx.scrollHeight - viewportHeight);
|
|
233
|
+
const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));
|
|
234
|
+
const dynamicOffset = effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset);
|
|
235
|
+
const triggerLine = scrollY + dynamicOffset;
|
|
236
|
+
const elementMap = new Map(sections.map((s)=>[
|
|
237
|
+
s.id,
|
|
238
|
+
s.element
|
|
239
|
+
]));
|
|
240
|
+
return sectionBounds.map((section)=>{
|
|
241
|
+
const visibleTop = Math.max(section.top, viewportTop);
|
|
242
|
+
const visibleBottom = Math.min(section.bottom, viewportBottom);
|
|
243
|
+
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
244
|
+
const visibilityRatio = section.height > 0 ? visibleHeight / section.height : 0;
|
|
245
|
+
const visibleInViewportRatio = viewportHeight > 0 ? visibleHeight / viewportHeight : 0;
|
|
246
|
+
const isInView = section.bottom > viewportTop && section.top < viewportBottom;
|
|
247
|
+
const sectionProgress = (()=>{
|
|
248
|
+
if (section.height === 0) return 0;
|
|
249
|
+
const entryPoint = viewportBottom;
|
|
250
|
+
const totalTravel = viewportHeight + section.height;
|
|
251
|
+
const traveled = entryPoint - section.top;
|
|
252
|
+
return Math.max(0, Math.min(1, traveled / totalTravel));
|
|
253
|
+
})();
|
|
254
|
+
let score = 0;
|
|
255
|
+
if (visibilityRatio >= visibilityThreshold) {
|
|
256
|
+
score += 1000 + visibilityRatio * 500;
|
|
257
|
+
} else if (isInView) {
|
|
258
|
+
score += visibleInViewportRatio * 800;
|
|
259
|
+
}
|
|
260
|
+
if (isInView) {
|
|
261
|
+
const containsTriggerLine = triggerLine >= section.top && triggerLine < section.bottom;
|
|
262
|
+
if (containsTriggerLine) {
|
|
263
|
+
score += 300;
|
|
264
|
+
}
|
|
265
|
+
const sectionCenter = section.top + section.height / 2;
|
|
266
|
+
const distanceFromTrigger = Math.abs(sectionCenter - triggerLine);
|
|
267
|
+
const maxDistance = viewportHeight;
|
|
268
|
+
const proximityScore = Math.max(0, 1 - distanceFromTrigger / maxDistance) * 500;
|
|
269
|
+
score += proximityScore;
|
|
270
|
+
}
|
|
271
|
+
const element = elementMap.get(section.id);
|
|
272
|
+
const rect = element ? element.getBoundingClientRect() : null;
|
|
273
|
+
return {
|
|
274
|
+
id: section.id,
|
|
275
|
+
score,
|
|
276
|
+
visibilityRatio,
|
|
277
|
+
inView: isInView,
|
|
278
|
+
bounds: section,
|
|
279
|
+
progress: sectionProgress,
|
|
280
|
+
rect
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function determineActiveSection(scores, sectionIds, currentActiveId, hysteresisMargin, scrollY, viewportHeight, scrollHeight) {
|
|
285
|
+
if (scores.length === 0 || sectionIds.length === 0) return null;
|
|
286
|
+
const scoredIds = new Set(scores.map((s)=>s.id));
|
|
287
|
+
const maxScroll = Math.max(0, scrollHeight - viewportHeight);
|
|
288
|
+
const hasScroll = maxScroll > MIN_SCROLL_THRESHOLD;
|
|
289
|
+
const isAtBottom = hasScroll && scrollY + viewportHeight >= scrollHeight - EDGE_TOLERANCE;
|
|
290
|
+
const isAtTop = hasScroll && scrollY <= EDGE_TOLERANCE;
|
|
291
|
+
if (isAtBottom && sectionIds.length >= 2) {
|
|
292
|
+
const lastId = sectionIds[sectionIds.length - 1];
|
|
293
|
+
const secondLastId = sectionIds[sectionIds.length - 2];
|
|
294
|
+
const secondLastScore = scores.find((s)=>s.id === secondLastId);
|
|
295
|
+
const secondLastNotVisible = !secondLastScore || !secondLastScore.inView;
|
|
296
|
+
if (scoredIds.has(lastId) && secondLastNotVisible) {
|
|
297
|
+
return lastId;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (isAtTop && sectionIds.length >= 2) {
|
|
301
|
+
const firstId = sectionIds[0];
|
|
302
|
+
const secondId = sectionIds[1];
|
|
303
|
+
const secondScore = scores.find((s)=>s.id === secondId);
|
|
304
|
+
const secondNotVisible = !secondScore || !secondScore.inView;
|
|
305
|
+
if (scoredIds.has(firstId) && secondNotVisible) {
|
|
306
|
+
return firstId;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const visibleScores = scores.filter((s)=>s.inView);
|
|
310
|
+
const candidates = visibleScores.length > 0 ? visibleScores : scores;
|
|
311
|
+
const sorted = [
|
|
312
|
+
...candidates
|
|
313
|
+
].sort((a, b)=>b.score - a.score);
|
|
314
|
+
if (sorted.length === 0) return null;
|
|
315
|
+
const bestCandidate = sorted[0];
|
|
316
|
+
const currentScore = scores.find((s)=>s.id === currentActiveId);
|
|
317
|
+
const shouldSwitch = !currentScore || !currentScore.inView || bestCandidate.score > currentScore.score + hysteresisMargin || bestCandidate.id === currentActiveId;
|
|
318
|
+
return shouldSwitch ? bestCandidate.id : currentActiveId;
|
|
319
|
+
}
|
|
320
|
+
|
|
8
321
|
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? react.useLayoutEffect : react.useEffect;
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
322
|
+
function areIdInputsEqual(a, b) {
|
|
323
|
+
if (Object.is(a, b)) return true;
|
|
324
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
325
|
+
if (a.length !== b.length) return false;
|
|
326
|
+
for(let i = 0; i < a.length; i++){
|
|
327
|
+
if (!Object.is(a[i], b[i])) return false;
|
|
328
|
+
}
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function useDomet(options) {
|
|
333
|
+
const { container: containerInput, tracking, scrolling, onActive, onEnter, onLeave, onScrollStart, onScrollEnd } = options;
|
|
334
|
+
const trackingOffset = sanitizeOffset(tracking?.offset);
|
|
335
|
+
const throttle = sanitizeThrottle(tracking?.throttle);
|
|
336
|
+
const threshold = sanitizeThreshold(tracking?.threshold);
|
|
337
|
+
const hysteresis = sanitizeHysteresis(tracking?.hysteresis);
|
|
338
|
+
const scrollingDefaults = react.useMemo(()=>{
|
|
339
|
+
if (!scrolling) {
|
|
340
|
+
return {
|
|
341
|
+
behavior: "auto",
|
|
342
|
+
offset: undefined,
|
|
343
|
+
position: undefined,
|
|
344
|
+
lockActive: undefined
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
behavior: scrolling.behavior ?? "auto",
|
|
349
|
+
offset: scrolling.offset !== undefined ? sanitizeOffset(scrolling.offset) : undefined,
|
|
350
|
+
position: scrolling.position,
|
|
351
|
+
lockActive: scrolling.lockActive
|
|
352
|
+
};
|
|
353
|
+
}, [
|
|
354
|
+
scrolling
|
|
14
355
|
]);
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
356
|
+
const rawIds = "ids" in options ? options.ids : undefined;
|
|
357
|
+
const rawSelector = "selector" in options ? options.selector : undefined;
|
|
358
|
+
const idsCacheRef = react.useRef({
|
|
359
|
+
raw: undefined,
|
|
360
|
+
sanitized: undefined
|
|
361
|
+
});
|
|
362
|
+
const idsArray = react.useMemo(()=>{
|
|
363
|
+
if (rawIds === undefined) {
|
|
364
|
+
idsCacheRef.current = {
|
|
365
|
+
raw: undefined,
|
|
366
|
+
sanitized: undefined
|
|
367
|
+
};
|
|
368
|
+
return undefined;
|
|
19
369
|
}
|
|
20
|
-
|
|
370
|
+
if (areIdInputsEqual(rawIds, idsCacheRef.current.raw)) {
|
|
371
|
+
idsCacheRef.current.raw = rawIds;
|
|
372
|
+
return idsCacheRef.current.sanitized;
|
|
373
|
+
}
|
|
374
|
+
const sanitized = sanitizeIds(rawIds);
|
|
375
|
+
idsCacheRef.current = {
|
|
376
|
+
raw: rawIds,
|
|
377
|
+
sanitized
|
|
378
|
+
};
|
|
379
|
+
return sanitized;
|
|
21
380
|
}, [
|
|
22
|
-
|
|
381
|
+
rawIds
|
|
23
382
|
]);
|
|
24
|
-
const
|
|
383
|
+
const selectorString = react.useMemo(()=>{
|
|
384
|
+
if (rawSelector === undefined) return undefined;
|
|
385
|
+
return sanitizeSelector(rawSelector);
|
|
386
|
+
}, [
|
|
387
|
+
rawSelector
|
|
388
|
+
]);
|
|
389
|
+
const useSelector = selectorString !== undefined && selectorString !== "";
|
|
390
|
+
const initialActiveId = idsArray && idsArray.length > 0 ? idsArray[0] : null;
|
|
391
|
+
const [containerElement, setContainerElement] = react.useState(null);
|
|
392
|
+
const [resolvedSections, setResolvedSections] = react.useState([]);
|
|
393
|
+
const [activeId, setActiveId] = react.useState(initialActiveId);
|
|
25
394
|
const [scroll, setScroll] = react.useState({
|
|
26
395
|
y: 0,
|
|
27
396
|
progress: 0,
|
|
28
397
|
direction: null,
|
|
29
398
|
velocity: 0,
|
|
30
|
-
|
|
399
|
+
scrolling: false,
|
|
31
400
|
maxScroll: 0,
|
|
32
401
|
viewportHeight: 0,
|
|
33
|
-
|
|
402
|
+
trackingOffset: 0,
|
|
403
|
+
triggerLine: 0
|
|
34
404
|
});
|
|
35
405
|
const [sections, setSections] = react.useState({});
|
|
36
|
-
const [containerElement, setContainerElement] = react.useState(null);
|
|
37
406
|
const refs = react.useRef({});
|
|
38
407
|
const refCallbacks = react.useRef({});
|
|
39
|
-
const
|
|
408
|
+
const registerPropsCache = react.useRef({});
|
|
409
|
+
const activeIdRef = react.useRef(initialActiveId);
|
|
40
410
|
const lastScrollY = react.useRef(0);
|
|
41
411
|
const lastScrollTime = react.useRef(Date.now());
|
|
42
412
|
const rafId = react.useRef(null);
|
|
@@ -44,50 +414,174 @@ function useDomet(sectionIds, containerRef = null, options = {}) {
|
|
|
44
414
|
const throttleTimeoutId = react.useRef(null);
|
|
45
415
|
const hasPendingScroll = react.useRef(false);
|
|
46
416
|
const isProgrammaticScrolling = react.useRef(false);
|
|
47
|
-
const programmaticScrollTimeoutId = react.useRef(null);
|
|
48
417
|
const isScrollingRef = react.useRef(false);
|
|
49
418
|
const scrollIdleTimeoutRef = react.useRef(null);
|
|
50
419
|
const prevSectionsInViewport = react.useRef(new Set());
|
|
51
420
|
const recalculateRef = react.useRef(()=>{});
|
|
421
|
+
const scheduleRecalculate = react.useCallback(()=>{
|
|
422
|
+
if (typeof window === "undefined") return;
|
|
423
|
+
if (rafId.current) {
|
|
424
|
+
cancelAnimationFrame(rafId.current);
|
|
425
|
+
}
|
|
426
|
+
rafId.current = requestAnimationFrame(()=>{
|
|
427
|
+
rafId.current = null;
|
|
428
|
+
recalculateRef.current();
|
|
429
|
+
});
|
|
430
|
+
}, []);
|
|
52
431
|
const scrollCleanupRef = react.useRef(null);
|
|
432
|
+
const mutationObserverRef = react.useRef(null);
|
|
433
|
+
const mutationDebounceRef = react.useRef(null);
|
|
434
|
+
const optionsRef = react.useRef({
|
|
435
|
+
trackingOffset,
|
|
436
|
+
scrolling: scrollingDefaults
|
|
437
|
+
});
|
|
53
438
|
const callbackRefs = react.useRef({
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
439
|
+
onActive,
|
|
440
|
+
onEnter,
|
|
441
|
+
onLeave,
|
|
57
442
|
onScrollStart,
|
|
58
443
|
onScrollEnd
|
|
59
444
|
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
445
|
+
useIsomorphicLayoutEffect(()=>{
|
|
446
|
+
optionsRef.current = {
|
|
447
|
+
trackingOffset,
|
|
448
|
+
scrolling: scrollingDefaults
|
|
449
|
+
};
|
|
450
|
+
}, [
|
|
451
|
+
trackingOffset,
|
|
452
|
+
scrollingDefaults
|
|
453
|
+
]);
|
|
454
|
+
react.useEffect(()=>{
|
|
455
|
+
scheduleRecalculate();
|
|
456
|
+
}, [
|
|
457
|
+
trackingOffset,
|
|
458
|
+
scheduleRecalculate
|
|
459
|
+
]);
|
|
460
|
+
useIsomorphicLayoutEffect(()=>{
|
|
461
|
+
callbackRefs.current = {
|
|
462
|
+
onActive,
|
|
463
|
+
onEnter,
|
|
464
|
+
onLeave,
|
|
465
|
+
onScrollStart,
|
|
466
|
+
onScrollEnd
|
|
467
|
+
};
|
|
468
|
+
}, [
|
|
469
|
+
onActive,
|
|
470
|
+
onEnter,
|
|
471
|
+
onLeave,
|
|
64
472
|
onScrollStart,
|
|
65
473
|
onScrollEnd
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
return
|
|
474
|
+
]);
|
|
475
|
+
const sectionIds = react.useMemo(()=>{
|
|
476
|
+
if (!useSelector && idsArray) return idsArray;
|
|
477
|
+
return resolvedSections.map((s)=>s.id);
|
|
69
478
|
}, [
|
|
70
|
-
|
|
479
|
+
useSelector,
|
|
480
|
+
idsArray,
|
|
481
|
+
resolvedSections
|
|
71
482
|
]);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return prefersReducedMotion ? "instant" : "smooth";
|
|
483
|
+
const sectionIndexMap = react.useMemo(()=>{
|
|
484
|
+
const map = new Map();
|
|
485
|
+
for(let i = 0; i < sectionIds.length; i++){
|
|
486
|
+
map.set(sectionIds[i], i);
|
|
77
487
|
}
|
|
78
|
-
return
|
|
488
|
+
return map;
|
|
79
489
|
}, [
|
|
80
|
-
|
|
490
|
+
sectionIds
|
|
81
491
|
]);
|
|
492
|
+
const containerRefCurrent = containerInput?.current ?? null;
|
|
82
493
|
useIsomorphicLayoutEffect(()=>{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
494
|
+
const resolved = resolveContainer(containerInput);
|
|
495
|
+
if (resolved !== containerElement) {
|
|
496
|
+
setContainerElement(resolved);
|
|
497
|
+
}
|
|
498
|
+
}, [
|
|
499
|
+
containerInput,
|
|
500
|
+
containerRefCurrent
|
|
501
|
+
]);
|
|
502
|
+
const updateSectionsFromSelector = react.useCallback((selector)=>{
|
|
503
|
+
const resolved = resolveSectionsFromSelector(selector);
|
|
504
|
+
setResolvedSections(resolved);
|
|
505
|
+
if (resolved.length > 0) {
|
|
506
|
+
const currentStillExists = resolved.some((s)=>s.id === activeIdRef.current);
|
|
507
|
+
if (!activeIdRef.current || !currentStillExists) {
|
|
508
|
+
activeIdRef.current = resolved[0].id;
|
|
509
|
+
setActiveId(resolved[0].id);
|
|
510
|
+
}
|
|
511
|
+
} else if (activeIdRef.current !== null) {
|
|
512
|
+
activeIdRef.current = null;
|
|
513
|
+
setActiveId(null);
|
|
514
|
+
}
|
|
515
|
+
}, []);
|
|
516
|
+
useIsomorphicLayoutEffect(()=>{
|
|
517
|
+
if (useSelector && selectorString) {
|
|
518
|
+
updateSectionsFromSelector(selectorString);
|
|
519
|
+
}
|
|
520
|
+
}, [
|
|
521
|
+
selectorString,
|
|
522
|
+
useSelector,
|
|
523
|
+
updateSectionsFromSelector
|
|
524
|
+
]);
|
|
525
|
+
react.useEffect(()=>{
|
|
526
|
+
if (!useSelector || !selectorString || typeof window === "undefined" || typeof MutationObserver === "undefined") {
|
|
527
|
+
return;
|
|
87
528
|
}
|
|
529
|
+
const handleMutation = ()=>{
|
|
530
|
+
if (mutationDebounceRef.current) {
|
|
531
|
+
clearTimeout(mutationDebounceRef.current);
|
|
532
|
+
}
|
|
533
|
+
mutationDebounceRef.current = setTimeout(()=>{
|
|
534
|
+
updateSectionsFromSelector(selectorString);
|
|
535
|
+
}, 50);
|
|
536
|
+
};
|
|
537
|
+
mutationObserverRef.current = new MutationObserver(handleMutation);
|
|
538
|
+
mutationObserverRef.current.observe(document.body, {
|
|
539
|
+
childList: true,
|
|
540
|
+
subtree: true,
|
|
541
|
+
attributes: true,
|
|
542
|
+
attributeFilter: [
|
|
543
|
+
"id",
|
|
544
|
+
"data-domet"
|
|
545
|
+
]
|
|
546
|
+
});
|
|
547
|
+
return ()=>{
|
|
548
|
+
if (mutationDebounceRef.current) {
|
|
549
|
+
clearTimeout(mutationDebounceRef.current);
|
|
550
|
+
mutationDebounceRef.current = null;
|
|
551
|
+
}
|
|
552
|
+
if (mutationObserverRef.current) {
|
|
553
|
+
mutationObserverRef.current.disconnect();
|
|
554
|
+
mutationObserverRef.current = null;
|
|
555
|
+
}
|
|
556
|
+
};
|
|
88
557
|
}, [
|
|
89
|
-
|
|
90
|
-
|
|
558
|
+
useSelector,
|
|
559
|
+
selectorString,
|
|
560
|
+
updateSectionsFromSelector
|
|
561
|
+
]);
|
|
562
|
+
react.useEffect(()=>{
|
|
563
|
+
if (!useSelector && idsArray) {
|
|
564
|
+
const idsSet = new Set(idsArray);
|
|
565
|
+
for (const id of Object.keys(refs.current)){
|
|
566
|
+
if (!idsSet.has(id)) {
|
|
567
|
+
delete refs.current[id];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
for (const id of Object.keys(refCallbacks.current)){
|
|
571
|
+
if (!idsSet.has(id)) {
|
|
572
|
+
delete refCallbacks.current[id];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const currentActive = activeIdRef.current;
|
|
576
|
+
const nextActive = currentActive && idsSet.has(currentActive) ? currentActive : idsArray[0] ?? null;
|
|
577
|
+
if (nextActive !== currentActive) {
|
|
578
|
+
activeIdRef.current = nextActive;
|
|
579
|
+
setActiveId(nextActive);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}, [
|
|
583
|
+
idsArray,
|
|
584
|
+
useSelector
|
|
91
585
|
]);
|
|
92
586
|
const registerRef = react.useCallback((id)=>{
|
|
93
587
|
const existing = refCallbacks.current[id];
|
|
@@ -98,323 +592,341 @@ function useDomet(sectionIds, containerRef = null, options = {}) {
|
|
|
98
592
|
} else {
|
|
99
593
|
delete refs.current[id];
|
|
100
594
|
}
|
|
595
|
+
scheduleRecalculate();
|
|
101
596
|
};
|
|
102
597
|
refCallbacks.current[id] = callback;
|
|
103
598
|
return callback;
|
|
104
|
-
}, [
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
599
|
+
}, [
|
|
600
|
+
scheduleRecalculate
|
|
601
|
+
]);
|
|
602
|
+
const getResolvedBehavior = react.useCallback((behaviorOverride)=>{
|
|
603
|
+
const b = behaviorOverride ?? optionsRef.current.scrolling.behavior;
|
|
604
|
+
if (b === "auto") {
|
|
605
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
606
|
+
return "smooth";
|
|
109
607
|
}
|
|
110
|
-
|
|
608
|
+
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
609
|
+
return prefersReducedMotion ? "instant" : "smooth";
|
|
111
610
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
611
|
+
return b;
|
|
612
|
+
}, []);
|
|
613
|
+
const getCurrentSections = react.useCallback(()=>{
|
|
614
|
+
if (!useSelector && idsArray) {
|
|
615
|
+
return resolveSectionsFromIds(idsArray, refs.current);
|
|
116
616
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
617
|
+
return resolvedSections;
|
|
618
|
+
}, [
|
|
619
|
+
useSelector,
|
|
620
|
+
idsArray,
|
|
621
|
+
resolvedSections
|
|
622
|
+
]);
|
|
623
|
+
const scrollTo = react.useCallback((target, scrollOptions)=>{
|
|
624
|
+
const resolvedTarget = typeof target === "string" ? {
|
|
625
|
+
type: "id",
|
|
626
|
+
id: target
|
|
627
|
+
} : "id" in target ? {
|
|
628
|
+
type: "id",
|
|
629
|
+
id: target.id
|
|
630
|
+
} : {
|
|
631
|
+
type: "top",
|
|
632
|
+
top: target.top
|
|
633
|
+
};
|
|
634
|
+
const defaultScroll = optionsRef.current.scrolling;
|
|
635
|
+
const lockActive = scrollOptions?.lockActive ?? defaultScroll.lockActive ?? resolvedTarget.type === "id";
|
|
121
636
|
const container = containerElement;
|
|
122
|
-
const elementRect = element.getBoundingClientRect();
|
|
123
|
-
const effectiveOffset = getEffectiveOffset() + 10;
|
|
124
637
|
const scrollTarget = container || window;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
638
|
+
const viewportHeight = container ? container.clientHeight : window.innerHeight;
|
|
639
|
+
const scrollHeight = container ? container.scrollHeight : document.documentElement.scrollHeight;
|
|
640
|
+
const maxScroll = Math.max(0, scrollHeight - viewportHeight);
|
|
641
|
+
const scrollBehavior = getResolvedBehavior(scrollOptions?.behavior ?? defaultScroll.behavior);
|
|
642
|
+
const offsetCandidate = scrollOptions?.offset ?? defaultScroll.offset;
|
|
643
|
+
const offsetValue = sanitizeOffset(offsetCandidate);
|
|
644
|
+
const effectiveOffset = resolveOffset(offsetValue, viewportHeight, DEFAULT_OFFSET);
|
|
645
|
+
const stopProgrammaticScroll = ()=>{
|
|
646
|
+
if (scrollCleanupRef.current) {
|
|
647
|
+
scrollCleanupRef.current();
|
|
648
|
+
scrollCleanupRef.current = null;
|
|
130
649
|
}
|
|
131
|
-
|
|
132
|
-
recalculateRef.current();
|
|
133
|
-
});
|
|
650
|
+
isProgrammaticScrolling.current = false;
|
|
134
651
|
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
652
|
+
if (!lockActive) {
|
|
653
|
+
stopProgrammaticScroll();
|
|
654
|
+
} else if (scrollCleanupRef.current) {
|
|
655
|
+
scrollCleanupRef.current();
|
|
656
|
+
}
|
|
657
|
+
const setupLock = ()=>{
|
|
658
|
+
const unlockScroll = ()=>{
|
|
659
|
+
isProgrammaticScrolling.current = false;
|
|
660
|
+
};
|
|
661
|
+
let debounceTimer = null;
|
|
662
|
+
let isUnlocked = false;
|
|
663
|
+
const cleanup = ()=>{
|
|
664
|
+
if (debounceTimer) {
|
|
665
|
+
clearTimeout(debounceTimer);
|
|
666
|
+
debounceTimer = null;
|
|
667
|
+
}
|
|
668
|
+
scrollTarget.removeEventListener("scroll", handleScrollActivity);
|
|
669
|
+
if ("onscrollend" in scrollTarget) {
|
|
670
|
+
scrollTarget.removeEventListener("scrollend", handleScrollEnd);
|
|
671
|
+
}
|
|
672
|
+
scrollCleanupRef.current = null;
|
|
673
|
+
};
|
|
674
|
+
const doUnlock = ()=>{
|
|
675
|
+
if (isUnlocked) return;
|
|
676
|
+
isUnlocked = true;
|
|
677
|
+
cleanup();
|
|
678
|
+
unlockScroll();
|
|
679
|
+
};
|
|
680
|
+
const resetDebounce = ()=>{
|
|
681
|
+
if (debounceTimer) {
|
|
682
|
+
clearTimeout(debounceTimer);
|
|
683
|
+
}
|
|
684
|
+
debounceTimer = setTimeout(doUnlock, SCROLL_IDLE_MS);
|
|
685
|
+
};
|
|
686
|
+
const handleScrollActivity = ()=>{
|
|
687
|
+
resetDebounce();
|
|
688
|
+
};
|
|
689
|
+
const handleScrollEnd = ()=>{
|
|
690
|
+
doUnlock();
|
|
691
|
+
};
|
|
692
|
+
scrollTarget.addEventListener("scroll", handleScrollActivity, {
|
|
693
|
+
passive: true
|
|
694
|
+
});
|
|
143
695
|
if ("onscrollend" in scrollTarget) {
|
|
144
|
-
scrollTarget.
|
|
696
|
+
scrollTarget.addEventListener("scrollend", handleScrollEnd, {
|
|
697
|
+
once: true
|
|
698
|
+
});
|
|
145
699
|
}
|
|
146
|
-
scrollCleanupRef.current =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
cleanup();
|
|
152
|
-
unlockScroll();
|
|
700
|
+
scrollCleanupRef.current = cleanup;
|
|
701
|
+
return {
|
|
702
|
+
doUnlock,
|
|
703
|
+
resetDebounce
|
|
704
|
+
};
|
|
153
705
|
};
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
706
|
+
const clampValue = (value, min, max)=>Math.max(min, Math.min(max, value));
|
|
707
|
+
let targetScroll = null;
|
|
708
|
+
let activeTargetId = null;
|
|
709
|
+
if (resolvedTarget.type === "id") {
|
|
710
|
+
const id = resolvedTarget.id;
|
|
711
|
+
if (!sectionIndexMap.has(id)) {
|
|
712
|
+
if (process.env.NODE_ENV !== "production") {
|
|
713
|
+
console.warn(`[domet] scrollTo: id "${id}" not found`);
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
157
716
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
717
|
+
const currentSections = getCurrentSections();
|
|
718
|
+
const section = currentSections.find((s)=>s.id === id);
|
|
719
|
+
if (!section) {
|
|
720
|
+
if (process.env.NODE_ENV !== "production") {
|
|
721
|
+
console.warn(`[domet] scrollTo: element for id "${id}" not yet mounted`);
|
|
722
|
+
}
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const elementRect = section.element.getBoundingClientRect();
|
|
726
|
+
const position = scrollOptions?.position ?? defaultScroll.position;
|
|
727
|
+
const sectionTop = container ? elementRect.top - container.getBoundingClientRect().top + container.scrollTop : elementRect.top + window.scrollY;
|
|
728
|
+
const sectionHeight = elementRect.height;
|
|
729
|
+
const calculateTargetScroll = ()=>{
|
|
730
|
+
if (maxScroll <= 0) return 0;
|
|
731
|
+
const topTarget = sectionTop - effectiveOffset;
|
|
732
|
+
const centerTarget = sectionTop - (viewportHeight - sectionHeight) / 2;
|
|
733
|
+
const bottomTarget = sectionTop + sectionHeight - viewportHeight;
|
|
734
|
+
if (position === "top") {
|
|
735
|
+
return clampValue(topTarget, 0, maxScroll);
|
|
736
|
+
}
|
|
737
|
+
if (position === "center") {
|
|
738
|
+
return clampValue(centerTarget, 0, maxScroll);
|
|
739
|
+
}
|
|
740
|
+
if (position === "bottom") {
|
|
741
|
+
return clampValue(bottomTarget, 0, maxScroll);
|
|
742
|
+
}
|
|
743
|
+
const fits = sectionHeight <= viewportHeight;
|
|
744
|
+
const dynamicRange = viewportHeight - effectiveOffset;
|
|
745
|
+
const denominator = dynamicRange !== 0 ? 1 + dynamicRange / maxScroll : 1;
|
|
746
|
+
const triggerMin = (sectionTop - effectiveOffset) / denominator;
|
|
747
|
+
const triggerMax = (sectionTop + sectionHeight - effectiveOffset) / denominator;
|
|
748
|
+
if (fits) {
|
|
749
|
+
if (centerTarget >= triggerMin && centerTarget <= triggerMax) {
|
|
750
|
+
return clampValue(centerTarget, 0, maxScroll);
|
|
751
|
+
}
|
|
752
|
+
if (centerTarget < triggerMin) {
|
|
753
|
+
return clampValue(triggerMin, 0, maxScroll);
|
|
754
|
+
}
|
|
755
|
+
return clampValue(triggerMax, 0, maxScroll);
|
|
756
|
+
}
|
|
757
|
+
return clampValue(topTarget, 0, maxScroll);
|
|
758
|
+
};
|
|
759
|
+
targetScroll = calculateTargetScroll();
|
|
760
|
+
activeTargetId = id;
|
|
761
|
+
} else {
|
|
762
|
+
const top = resolvedTarget.top;
|
|
763
|
+
if (!Number.isFinite(top)) {
|
|
764
|
+
if (process.env.NODE_ENV !== "production") {
|
|
765
|
+
console.warn(`[domet] scrollTo: top "${top}" is not a valid number`);
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
targetScroll = clampValue(top - effectiveOffset, 0, maxScroll);
|
|
173
770
|
}
|
|
174
|
-
|
|
175
|
-
|
|
771
|
+
if (targetScroll === null) return;
|
|
772
|
+
if (lockActive) {
|
|
773
|
+
isProgrammaticScrolling.current = true;
|
|
774
|
+
if (activeTargetId) {
|
|
775
|
+
activeIdRef.current = activeTargetId;
|
|
776
|
+
setActiveId(activeTargetId);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const lockControls = lockActive ? setupLock() : null;
|
|
176
780
|
if (container) {
|
|
177
|
-
const containerRect = container.getBoundingClientRect();
|
|
178
|
-
const relativeTop = elementRect.top - containerRect.top + container.scrollTop;
|
|
179
781
|
container.scrollTo({
|
|
180
|
-
top:
|
|
782
|
+
top: targetScroll,
|
|
181
783
|
behavior: scrollBehavior
|
|
182
784
|
});
|
|
183
785
|
} else {
|
|
184
|
-
const absoluteTop = elementRect.top + window.scrollY;
|
|
185
786
|
window.scrollTo({
|
|
186
|
-
top:
|
|
787
|
+
top: targetScroll,
|
|
187
788
|
behavior: scrollBehavior
|
|
188
789
|
});
|
|
189
790
|
}
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
791
|
+
if (lockControls) {
|
|
792
|
+
if (scrollBehavior === "instant") {
|
|
793
|
+
lockControls.doUnlock();
|
|
794
|
+
} else {
|
|
795
|
+
lockControls.resetDebounce();
|
|
796
|
+
}
|
|
194
797
|
}
|
|
195
798
|
}, [
|
|
196
|
-
|
|
799
|
+
sectionIndexMap,
|
|
197
800
|
containerElement,
|
|
198
|
-
|
|
199
|
-
|
|
801
|
+
getResolvedBehavior,
|
|
802
|
+
getCurrentSections
|
|
200
803
|
]);
|
|
201
|
-
const
|
|
804
|
+
const register = react.useCallback((id)=>{
|
|
805
|
+
const cached = registerPropsCache.current[id];
|
|
806
|
+
if (cached) return cached;
|
|
807
|
+
const props = {
|
|
202
808
|
id,
|
|
203
809
|
ref: registerRef(id),
|
|
204
810
|
"data-domet": id
|
|
205
|
-
}
|
|
811
|
+
};
|
|
812
|
+
registerPropsCache.current[id] = props;
|
|
813
|
+
return props;
|
|
814
|
+
}, [
|
|
206
815
|
registerRef
|
|
207
816
|
]);
|
|
208
|
-
const
|
|
209
|
-
onClick: ()=>
|
|
817
|
+
const link = react.useCallback((id, options)=>({
|
|
818
|
+
onClick: ()=>scrollTo(id, options),
|
|
210
819
|
"aria-current": activeId === id ? "page" : undefined,
|
|
211
820
|
"data-active": activeId === id
|
|
212
821
|
}), [
|
|
213
822
|
activeId,
|
|
214
|
-
|
|
215
|
-
]);
|
|
216
|
-
react.useEffect(()=>{
|
|
217
|
-
var _stableSectionIds_;
|
|
218
|
-
const idsSet = new Set(stableSectionIds);
|
|
219
|
-
for (const id of Object.keys(refs.current)){
|
|
220
|
-
if (!idsSet.has(id)) {
|
|
221
|
-
delete refs.current[id];
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
for (const id of Object.keys(refCallbacks.current)){
|
|
225
|
-
if (!idsSet.has(id)) {
|
|
226
|
-
delete refCallbacks.current[id];
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const currentActive = activeIdRef.current;
|
|
230
|
-
const nextActive = currentActive && idsSet.has(currentActive) ? currentActive : (_stableSectionIds_ = stableSectionIds[0]) != null ? _stableSectionIds_ : null;
|
|
231
|
-
if (nextActive !== currentActive) {
|
|
232
|
-
activeIdRef.current = nextActive;
|
|
233
|
-
}
|
|
234
|
-
setActiveId((prev)=>prev !== nextActive ? nextActive : prev);
|
|
235
|
-
}, [
|
|
236
|
-
stableSectionIds
|
|
237
|
-
]);
|
|
238
|
-
const getSectionBounds = react.useCallback(()=>{
|
|
239
|
-
const container = containerElement;
|
|
240
|
-
const scrollTop = container ? container.scrollTop : window.scrollY;
|
|
241
|
-
const containerTop = container ? container.getBoundingClientRect().top : 0;
|
|
242
|
-
return stableSectionIds.map((id)=>{
|
|
243
|
-
const el = refs.current[id];
|
|
244
|
-
if (!el) return null;
|
|
245
|
-
const rect = el.getBoundingClientRect();
|
|
246
|
-
const relativeTop = container ? rect.top - containerTop + scrollTop : rect.top + window.scrollY;
|
|
247
|
-
return {
|
|
248
|
-
id,
|
|
249
|
-
top: relativeTop,
|
|
250
|
-
bottom: relativeTop + rect.height,
|
|
251
|
-
height: rect.height
|
|
252
|
-
};
|
|
253
|
-
}).filter((bounds)=>bounds !== null);
|
|
254
|
-
}, [
|
|
255
|
-
stableSectionIds,
|
|
256
|
-
containerElement
|
|
823
|
+
scrollTo
|
|
257
824
|
]);
|
|
258
825
|
const calculateActiveSection = react.useCallback(()=>{
|
|
259
|
-
if (isProgrammaticScrolling.current) return;
|
|
260
826
|
const container = containerElement;
|
|
261
827
|
const currentActiveId = activeIdRef.current;
|
|
262
828
|
const now = Date.now();
|
|
263
829
|
const scrollY = container ? container.scrollTop : window.scrollY;
|
|
264
830
|
const viewportHeight = container ? container.clientHeight : window.innerHeight;
|
|
265
831
|
const scrollHeight = container ? container.scrollHeight : document.documentElement.scrollHeight;
|
|
266
|
-
const maxScroll = Math.max(
|
|
267
|
-
const scrollProgress =
|
|
832
|
+
const maxScroll = Math.max(1, scrollHeight - viewportHeight);
|
|
833
|
+
const scrollProgress = Math.min(1, Math.max(0, scrollY / maxScroll));
|
|
268
834
|
const scrollDirection = scrollY === lastScrollY.current ? null : scrollY > lastScrollY.current ? "down" : "up";
|
|
269
835
|
const deltaTime = now - lastScrollTime.current;
|
|
270
836
|
const deltaY = scrollY - lastScrollY.current;
|
|
271
837
|
const velocity = deltaTime > 0 ? Math.abs(deltaY) / deltaTime : 0;
|
|
272
838
|
lastScrollY.current = scrollY;
|
|
273
839
|
lastScrollTime.current = now;
|
|
274
|
-
const
|
|
840
|
+
const currentSections = getCurrentSections();
|
|
841
|
+
const sectionBounds = getSectionBounds(currentSections, container);
|
|
275
842
|
if (sectionBounds.length === 0) return;
|
|
276
|
-
const
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const visibilityRatio = section.height > 0 ? visibleHeight / section.height : 0;
|
|
287
|
-
const visibleInViewportRatio = viewportHeight > 0 ? visibleHeight / viewportHeight : 0;
|
|
288
|
-
const isInViewport = section.bottom > viewportTop && section.top < viewportBottom;
|
|
289
|
-
const sectionProgress = (()=>{
|
|
290
|
-
if (section.height === 0) return 0;
|
|
291
|
-
const entryPoint = viewportBottom;
|
|
292
|
-
const totalTravel = viewportHeight + section.height;
|
|
293
|
-
const traveled = entryPoint - section.top;
|
|
294
|
-
return Math.max(0, Math.min(1, traveled / totalTravel));
|
|
295
|
-
})();
|
|
296
|
-
let score = 0;
|
|
297
|
-
if (visibilityRatio >= visibilityThreshold) {
|
|
298
|
-
score += 1000 + visibilityRatio * 500;
|
|
299
|
-
} else if (isInViewport) {
|
|
300
|
-
score += visibleInViewportRatio * 800;
|
|
301
|
-
}
|
|
302
|
-
const sectionIndex = (_sectionIndexMap_get = sectionIndexMap.get(section.id)) != null ? _sectionIndexMap_get : 0;
|
|
303
|
-
if (scrollDirection && isInViewport && section.top <= triggerLine && section.bottom > triggerLine) {
|
|
304
|
-
score += 200;
|
|
305
|
-
}
|
|
306
|
-
score -= sectionIndex * 0.1;
|
|
307
|
-
return {
|
|
308
|
-
id: section.id,
|
|
309
|
-
score,
|
|
310
|
-
visibilityRatio,
|
|
311
|
-
isInViewport,
|
|
312
|
-
bounds: section,
|
|
313
|
-
progress: sectionProgress
|
|
314
|
-
};
|
|
315
|
-
});
|
|
316
|
-
const hasScroll = maxScroll > 10;
|
|
317
|
-
const isAtBottom = hasScroll && scrollY + viewportHeight >= scrollHeight - 5;
|
|
318
|
-
const isAtTop = hasScroll && scrollY <= 5;
|
|
319
|
-
let newActiveId = null;
|
|
320
|
-
if (isAtBottom && stableSectionIds.length > 0) {
|
|
321
|
-
newActiveId = stableSectionIds[stableSectionIds.length - 1];
|
|
322
|
-
} else if (isAtTop && stableSectionIds.length > 0) {
|
|
323
|
-
newActiveId = stableSectionIds[0];
|
|
324
|
-
} else {
|
|
325
|
-
const visibleScores = scores.filter((s)=>s.isInViewport);
|
|
326
|
-
const candidates = visibleScores.length > 0 ? visibleScores : scores;
|
|
327
|
-
candidates.sort((a, b)=>b.score - a.score);
|
|
328
|
-
if (candidates.length > 0) {
|
|
329
|
-
const bestCandidate = candidates[0];
|
|
330
|
-
const currentScore = scores.find((s)=>s.id === currentActiveId);
|
|
331
|
-
const shouldSwitch = !currentScore || !currentScore.isInViewport || bestCandidate.score > currentScore.score + hysteresisMargin || bestCandidate.id === currentActiveId;
|
|
332
|
-
newActiveId = shouldSwitch ? bestCandidate.id : currentActiveId;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
if (newActiveId !== currentActiveId) {
|
|
843
|
+
const effectiveOffset = resolveOffset(trackingOffset, viewportHeight, DEFAULT_OFFSET);
|
|
844
|
+
const scores = calculateSectionScores(sectionBounds, currentSections, {
|
|
845
|
+
scrollY,
|
|
846
|
+
viewportHeight,
|
|
847
|
+
scrollHeight,
|
|
848
|
+
effectiveOffset,
|
|
849
|
+
visibilityThreshold: threshold});
|
|
850
|
+
const isProgrammatic = isProgrammaticScrolling.current;
|
|
851
|
+
const newActiveId = isProgrammatic ? currentActiveId : determineActiveSection(scores, sectionIds, currentActiveId, hysteresis, scrollY, viewportHeight, scrollHeight);
|
|
852
|
+
if (!isProgrammatic && newActiveId !== currentActiveId) {
|
|
336
853
|
activeIdRef.current = newActiveId;
|
|
337
854
|
setActiveId(newActiveId);
|
|
338
|
-
callbackRefs.current.
|
|
855
|
+
callbackRefs.current.onActive?.(newActiveId, currentActiveId);
|
|
339
856
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
857
|
+
if (!isProgrammatic) {
|
|
858
|
+
const currentInViewport = new Set(scores.filter((s)=>s.inView).map((s)=>s.id));
|
|
859
|
+
const prevInViewport = prevSectionsInViewport.current;
|
|
860
|
+
for (const id of currentInViewport){
|
|
861
|
+
if (!prevInViewport.has(id)) {
|
|
862
|
+
callbackRefs.current.onEnter?.(id);
|
|
863
|
+
}
|
|
345
864
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
865
|
+
for (const id of prevInViewport){
|
|
866
|
+
if (!currentInViewport.has(id)) {
|
|
867
|
+
callbackRefs.current.onLeave?.(id);
|
|
868
|
+
}
|
|
350
869
|
}
|
|
870
|
+
prevSectionsInViewport.current = currentInViewport;
|
|
351
871
|
}
|
|
352
|
-
|
|
872
|
+
const triggerLine = Math.round(effectiveOffset + scrollProgress * (viewportHeight - effectiveOffset));
|
|
353
873
|
const newScrollState = {
|
|
354
|
-
y: scrollY,
|
|
874
|
+
y: Math.round(scrollY),
|
|
355
875
|
progress: Math.max(0, Math.min(1, scrollProgress)),
|
|
356
876
|
direction: scrollDirection,
|
|
357
|
-
velocity,
|
|
358
|
-
|
|
359
|
-
maxScroll,
|
|
360
|
-
viewportHeight,
|
|
361
|
-
|
|
877
|
+
velocity: Math.round(velocity),
|
|
878
|
+
scrolling: isScrollingRef.current,
|
|
879
|
+
maxScroll: Math.round(maxScroll),
|
|
880
|
+
viewportHeight: Math.round(viewportHeight),
|
|
881
|
+
trackingOffset: Math.round(effectiveOffset),
|
|
882
|
+
triggerLine
|
|
362
883
|
};
|
|
363
884
|
const newSections = {};
|
|
364
885
|
for (const s of scores){
|
|
365
886
|
newSections[s.id] = {
|
|
366
887
|
bounds: {
|
|
367
|
-
top: s.bounds.top,
|
|
368
|
-
bottom: s.bounds.bottom,
|
|
369
|
-
height: s.bounds.height
|
|
888
|
+
top: Math.round(s.bounds.top),
|
|
889
|
+
bottom: Math.round(s.bounds.bottom),
|
|
890
|
+
height: Math.round(s.bounds.height)
|
|
370
891
|
},
|
|
371
892
|
visibility: Math.round(s.visibilityRatio * 100) / 100,
|
|
372
893
|
progress: Math.round(s.progress * 100) / 100,
|
|
373
|
-
|
|
374
|
-
|
|
894
|
+
inView: s.inView,
|
|
895
|
+
active: s.id === (isProgrammatic ? currentActiveId : newActiveId),
|
|
896
|
+
rect: s.rect
|
|
375
897
|
};
|
|
376
898
|
}
|
|
377
899
|
setScroll(newScrollState);
|
|
378
900
|
setSections(newSections);
|
|
379
901
|
}, [
|
|
380
|
-
|
|
902
|
+
sectionIds,
|
|
381
903
|
sectionIndexMap,
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
containerElement
|
|
904
|
+
trackingOffset,
|
|
905
|
+
threshold,
|
|
906
|
+
hysteresis,
|
|
907
|
+
containerElement,
|
|
908
|
+
getCurrentSections
|
|
388
909
|
]);
|
|
389
910
|
recalculateRef.current = calculateActiveSection;
|
|
390
911
|
react.useEffect(()=>{
|
|
391
912
|
const container = containerElement;
|
|
392
913
|
const scrollTarget = container || window;
|
|
393
|
-
const scheduleCalculate = ()=>{
|
|
394
|
-
if (rafId.current) {
|
|
395
|
-
cancelAnimationFrame(rafId.current);
|
|
396
|
-
}
|
|
397
|
-
rafId.current = requestAnimationFrame(()=>{
|
|
398
|
-
rafId.current = null;
|
|
399
|
-
calculateActiveSection();
|
|
400
|
-
});
|
|
401
|
-
};
|
|
402
914
|
const handleScrollEnd = ()=>{
|
|
403
915
|
isScrollingRef.current = false;
|
|
404
916
|
setScroll((prev)=>({
|
|
405
917
|
...prev,
|
|
406
|
-
|
|
918
|
+
scrolling: false
|
|
407
919
|
}));
|
|
408
|
-
callbackRefs.current.onScrollEnd
|
|
920
|
+
callbackRefs.current.onScrollEnd?.();
|
|
409
921
|
};
|
|
410
922
|
const handleScroll = ()=>{
|
|
411
923
|
if (!isScrollingRef.current) {
|
|
412
924
|
isScrollingRef.current = true;
|
|
413
925
|
setScroll((prev)=>({
|
|
414
926
|
...prev,
|
|
415
|
-
|
|
927
|
+
scrolling: true
|
|
416
928
|
}));
|
|
417
|
-
callbackRefs.current.onScrollStart
|
|
929
|
+
callbackRefs.current.onScrollStart?.();
|
|
418
930
|
}
|
|
419
931
|
if (scrollIdleTimeoutRef.current) {
|
|
420
932
|
clearTimeout(scrollIdleTimeoutRef.current);
|
|
@@ -429,7 +941,7 @@ function useDomet(sectionIds, containerRef = null, options = {}) {
|
|
|
429
941
|
if (throttleTimeoutId.current) {
|
|
430
942
|
clearTimeout(throttleTimeoutId.current);
|
|
431
943
|
}
|
|
432
|
-
|
|
944
|
+
scheduleRecalculate();
|
|
433
945
|
throttleTimeoutId.current = setTimeout(()=>{
|
|
434
946
|
isThrottled.current = false;
|
|
435
947
|
throttleTimeoutId.current = null;
|
|
@@ -437,14 +949,16 @@ function useDomet(sectionIds, containerRef = null, options = {}) {
|
|
|
437
949
|
hasPendingScroll.current = false;
|
|
438
950
|
handleScroll();
|
|
439
951
|
}
|
|
440
|
-
},
|
|
952
|
+
}, throttle);
|
|
441
953
|
};
|
|
442
954
|
const handleResize = ()=>{
|
|
443
|
-
|
|
955
|
+
if (useSelector && selectorString) {
|
|
956
|
+
updateSectionsFromSelector(selectorString);
|
|
957
|
+
}
|
|
958
|
+
scheduleRecalculate();
|
|
444
959
|
};
|
|
445
|
-
calculateActiveSection();
|
|
446
960
|
const deferredRecalcId = setTimeout(()=>{
|
|
447
|
-
|
|
961
|
+
scheduleRecalculate();
|
|
448
962
|
}, 0);
|
|
449
963
|
scrollTarget.addEventListener("scroll", handleScroll, {
|
|
450
964
|
passive: true
|
|
@@ -464,44 +978,45 @@ function useDomet(sectionIds, containerRef = null, options = {}) {
|
|
|
464
978
|
clearTimeout(throttleTimeoutId.current);
|
|
465
979
|
throttleTimeoutId.current = null;
|
|
466
980
|
}
|
|
467
|
-
if (programmaticScrollTimeoutId.current) {
|
|
468
|
-
clearTimeout(programmaticScrollTimeoutId.current);
|
|
469
|
-
programmaticScrollTimeoutId.current = null;
|
|
470
|
-
}
|
|
471
981
|
if (scrollIdleTimeoutRef.current) {
|
|
472
982
|
clearTimeout(scrollIdleTimeoutRef.current);
|
|
473
983
|
scrollIdleTimeoutRef.current = null;
|
|
474
984
|
}
|
|
475
|
-
scrollCleanupRef.current
|
|
985
|
+
scrollCleanupRef.current?.();
|
|
476
986
|
isThrottled.current = false;
|
|
477
987
|
hasPendingScroll.current = false;
|
|
478
988
|
isProgrammaticScrolling.current = false;
|
|
479
989
|
isScrollingRef.current = false;
|
|
480
990
|
};
|
|
481
991
|
}, [
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
992
|
+
throttle,
|
|
993
|
+
containerElement,
|
|
994
|
+
useSelector,
|
|
995
|
+
selectorString,
|
|
996
|
+
updateSectionsFromSelector,
|
|
997
|
+
scheduleRecalculate
|
|
485
998
|
]);
|
|
486
|
-
const
|
|
487
|
-
var _sectionIndexMap_get;
|
|
999
|
+
const index = react.useMemo(()=>{
|
|
488
1000
|
if (!activeId) return -1;
|
|
489
|
-
return
|
|
1001
|
+
return sectionIndexMap.get(activeId) ?? -1;
|
|
490
1002
|
}, [
|
|
491
1003
|
activeId,
|
|
492
1004
|
sectionIndexMap
|
|
493
1005
|
]);
|
|
494
1006
|
return {
|
|
495
|
-
activeId,
|
|
496
|
-
|
|
1007
|
+
active: activeId,
|
|
1008
|
+
index,
|
|
1009
|
+
progress: scroll.progress,
|
|
1010
|
+
direction: scroll.direction,
|
|
497
1011
|
scroll,
|
|
498
1012
|
sections,
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
1013
|
+
ids: sectionIds,
|
|
1014
|
+
scrollTo,
|
|
1015
|
+
register,
|
|
1016
|
+
link
|
|
503
1017
|
};
|
|
504
1018
|
}
|
|
505
1019
|
|
|
1020
|
+
exports.VALIDATION_LIMITS = VALIDATION_LIMITS;
|
|
506
1021
|
exports.default = useDomet;
|
|
507
1022
|
exports.useDomet = useDomet;
|