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