scroll-system 1.0.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/LICENSE +21 -0
- package/README.md +458 -0
- package/dist/index.d.mts +2010 -0
- package/dist/index.d.ts +2010 -0
- package/dist/index.js +1520 -0
- package/dist/index.mjs +1463 -0
- package/package.json +66 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1463 @@
|
|
|
1
|
+
// components/ScrollContainer.tsx
|
|
2
|
+
import { useEffect as useEffect7, useRef as useRef6, useMemo as useMemo2, useState as useState2 } from "react";
|
|
3
|
+
|
|
4
|
+
// store/navigation.store.ts
|
|
5
|
+
import { create } from "zustand";
|
|
6
|
+
import { subscribeWithSelector } from "zustand/middleware";
|
|
7
|
+
|
|
8
|
+
// constants.ts
|
|
9
|
+
var DEFAULT_TRANSITION_DURATION = 700;
|
|
10
|
+
var DEFAULT_TRANSITION_EASING = "cubic-bezier(0.645, 0.045, 0.355, 1.000)";
|
|
11
|
+
var DEFAULT_PROGRESS_DEBOUNCE = 16;
|
|
12
|
+
var NAVIGATION_COOLDOWN = 500;
|
|
13
|
+
var NAV_THRESHOLDS = {
|
|
14
|
+
WHEEL: 60,
|
|
15
|
+
// Acumulado de deltaY para disparar navegación
|
|
16
|
+
TOUCH: 50
|
|
17
|
+
// Píxeles de distancia para considerar swipe
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// store/navigation.store.ts
|
|
21
|
+
function evaluateStateMachine(capability, progress, viewType, explicitLock) {
|
|
22
|
+
if (explicitLock) return explicitLock;
|
|
23
|
+
if (capability === "none") return "unlocked";
|
|
24
|
+
if (viewType === "full") return "unlocked";
|
|
25
|
+
if (progress >= 0.99) return "unlocked";
|
|
26
|
+
return "locked";
|
|
27
|
+
}
|
|
28
|
+
function calculateCapability(metrics) {
|
|
29
|
+
if (metrics.scrollHeight - metrics.clientHeight <= 1) {
|
|
30
|
+
return "none";
|
|
31
|
+
}
|
|
32
|
+
return "internal";
|
|
33
|
+
}
|
|
34
|
+
function calculateProgress(metrics) {
|
|
35
|
+
const maxScroll = metrics.scrollHeight - metrics.clientHeight;
|
|
36
|
+
if (maxScroll <= 1) return 1;
|
|
37
|
+
return Math.max(0, Math.min(1, metrics.scrollTop / maxScroll));
|
|
38
|
+
}
|
|
39
|
+
var initialState = {
|
|
40
|
+
views: [],
|
|
41
|
+
activeIndex: 0,
|
|
42
|
+
activeId: null,
|
|
43
|
+
totalViews: 0,
|
|
44
|
+
isInitialized: false,
|
|
45
|
+
isTransitioning: false,
|
|
46
|
+
isGlobalLocked: false,
|
|
47
|
+
isDragging: false,
|
|
48
|
+
globalProgress: 0
|
|
49
|
+
};
|
|
50
|
+
var lastNavigationTime = 0;
|
|
51
|
+
var useScrollStore = create()(
|
|
52
|
+
subscribeWithSelector((set, get) => ({
|
|
53
|
+
...initialState,
|
|
54
|
+
initialize: () => {
|
|
55
|
+
const { views } = get();
|
|
56
|
+
if (views.length > 0) {
|
|
57
|
+
set({
|
|
58
|
+
isInitialized: true,
|
|
59
|
+
activeId: views[0]?.id ?? null,
|
|
60
|
+
activeIndex: 0
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
registerView: (config) => {
|
|
65
|
+
set((state) => {
|
|
66
|
+
if (state.views.some((v) => v.id === config.id)) return state;
|
|
67
|
+
const newIndex = state.views.length;
|
|
68
|
+
const newView = {
|
|
69
|
+
id: config.id,
|
|
70
|
+
index: newIndex,
|
|
71
|
+
type: config.type,
|
|
72
|
+
isActive: newIndex === 0,
|
|
73
|
+
capability: "none",
|
|
74
|
+
navigation: "unlocked",
|
|
75
|
+
explicitLock: null,
|
|
76
|
+
progress: 0,
|
|
77
|
+
metrics: { scrollHeight: 0, clientHeight: 0, scrollTop: 0 },
|
|
78
|
+
config
|
|
79
|
+
};
|
|
80
|
+
const newViews = [...state.views, newView];
|
|
81
|
+
return {
|
|
82
|
+
views: newViews,
|
|
83
|
+
totalViews: newViews.length,
|
|
84
|
+
activeId: state.activeId ?? newView.id
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
unregisterView: (id) => {
|
|
89
|
+
set((state) => {
|
|
90
|
+
const newViews = state.views.filter((v) => v.id !== id).map((v, idx) => ({ ...v, index: idx }));
|
|
91
|
+
const newActiveIndex = Math.min(state.activeIndex, newViews.length - 1);
|
|
92
|
+
return {
|
|
93
|
+
views: newViews,
|
|
94
|
+
totalViews: newViews.length,
|
|
95
|
+
activeIndex: Math.max(0, newActiveIndex),
|
|
96
|
+
activeId: newViews[newActiveIndex]?.id ?? null
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
updateViewMetrics: (id, metrics) => {
|
|
101
|
+
set((state) => {
|
|
102
|
+
const viewIndex = state.views.findIndex((v) => v.id === id);
|
|
103
|
+
if (viewIndex === -1) return state;
|
|
104
|
+
const view = state.views[viewIndex];
|
|
105
|
+
const capability = calculateCapability(metrics);
|
|
106
|
+
const progress = calculateProgress(metrics);
|
|
107
|
+
const navigation = evaluateStateMachine(capability, progress, view.type, view.explicitLock);
|
|
108
|
+
if (view.capability === capability && Math.abs(view.progress - progress) < 1e-4 && view.navigation === navigation) {
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
const newViews = [...state.views];
|
|
112
|
+
newViews[viewIndex] = {
|
|
113
|
+
...view,
|
|
114
|
+
metrics,
|
|
115
|
+
capability,
|
|
116
|
+
progress,
|
|
117
|
+
navigation
|
|
118
|
+
};
|
|
119
|
+
return { views: newViews };
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
processIntention: (intention) => {
|
|
123
|
+
const state = get();
|
|
124
|
+
if (state.isTransitioning || state.isGlobalLocked) return false;
|
|
125
|
+
const activeView = state.views[state.activeIndex];
|
|
126
|
+
if (!activeView) return false;
|
|
127
|
+
if (intention.type === "navigate") {
|
|
128
|
+
if (intention.direction === "down") {
|
|
129
|
+
if (activeView.navigation === "locked") return false;
|
|
130
|
+
if (state.activeIndex < state.totalViews - 1) {
|
|
131
|
+
get().goToView(state.activeIndex + 1);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
} else if (intention.direction === "up") {
|
|
135
|
+
const isAtTop = activeView.metrics.scrollTop <= 1;
|
|
136
|
+
if (activeView.capability === "internal" && !isAtTop) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (activeView.type === "controlled") {
|
|
140
|
+
const config = activeView.config;
|
|
141
|
+
if (config.allowGoBack === false) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
if (activeView.explicitLock === "locked") return false;
|
|
146
|
+
}
|
|
147
|
+
if (state.activeIndex > 0) {
|
|
148
|
+
get().goToView(state.activeIndex - 1);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
},
|
|
155
|
+
goToNext: () => {
|
|
156
|
+
const state = get();
|
|
157
|
+
state.goToView(state.activeIndex + 1);
|
|
158
|
+
},
|
|
159
|
+
goToPrevious: () => {
|
|
160
|
+
const state = get();
|
|
161
|
+
state.goToView(state.activeIndex - 1);
|
|
162
|
+
},
|
|
163
|
+
goToView: (indexOrId) => {
|
|
164
|
+
const state = get();
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
if (now - lastNavigationTime < NAVIGATION_COOLDOWN) return;
|
|
167
|
+
lastNavigationTime = now;
|
|
168
|
+
let targetIndex = typeof indexOrId === "string" ? state.views.findIndex((v) => v.id === indexOrId) : indexOrId;
|
|
169
|
+
if (targetIndex < 0 || targetIndex >= state.totalViews) return;
|
|
170
|
+
if (targetIndex === state.activeIndex) return;
|
|
171
|
+
set((s) => ({
|
|
172
|
+
...s,
|
|
173
|
+
isTransitioning: true,
|
|
174
|
+
activeIndex: targetIndex,
|
|
175
|
+
activeId: s.views[targetIndex]?.id ?? null,
|
|
176
|
+
views: s.views.map((v) => {
|
|
177
|
+
if (v.index === targetIndex) return { ...v, isActive: true };
|
|
178
|
+
if (v.index === s.activeIndex) return { ...v, isActive: false };
|
|
179
|
+
return v;
|
|
180
|
+
})
|
|
181
|
+
}));
|
|
182
|
+
},
|
|
183
|
+
setViewExplicitLock: (id, lock) => {
|
|
184
|
+
set((state) => {
|
|
185
|
+
const index = state.views.findIndex((v) => v.id === id);
|
|
186
|
+
if (index === -1) return state;
|
|
187
|
+
const view = state.views[index];
|
|
188
|
+
const navigation = evaluateStateMachine(view.capability, view.progress, view.type, lock);
|
|
189
|
+
const newViews = [...state.views];
|
|
190
|
+
newViews[index] = { ...view, explicitLock: lock, navigation };
|
|
191
|
+
return { views: newViews };
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
setGlobalLock: (locked) => set({ isGlobalLocked: locked }),
|
|
195
|
+
setDragging: (dragging) => set({ isDragging: dragging }),
|
|
196
|
+
getViewById: (id) => get().views.find((v) => v.id === id),
|
|
197
|
+
startTransition: () => set({ isTransitioning: true }),
|
|
198
|
+
endTransition: () => set({ isTransitioning: false }),
|
|
199
|
+
resetNavigationCooldown: () => {
|
|
200
|
+
lastNavigationTime = 0;
|
|
201
|
+
}
|
|
202
|
+
}))
|
|
203
|
+
);
|
|
204
|
+
var selectActiveView = (state) => state.views[state.activeIndex];
|
|
205
|
+
var selectActiveViewProgress = (state) => state.views[state.activeIndex]?.progress ?? 0;
|
|
206
|
+
var selectCanNavigateNext = (state) => {
|
|
207
|
+
if (state.isTransitioning || state.isGlobalLocked) return false;
|
|
208
|
+
const activeView = state.views[state.activeIndex];
|
|
209
|
+
if (!activeView) return false;
|
|
210
|
+
return activeView.navigation === "unlocked";
|
|
211
|
+
};
|
|
212
|
+
var selectCanNavigatePrevious = (state) => {
|
|
213
|
+
if (state.isTransitioning || state.isGlobalLocked) return false;
|
|
214
|
+
return state.activeIndex > 0;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// hooks/useWheelHandler.ts
|
|
218
|
+
import { useEffect, useRef } from "react";
|
|
219
|
+
|
|
220
|
+
// utils/normalizeWheel.ts
|
|
221
|
+
function normalizeWheel(event) {
|
|
222
|
+
let pixelX = 0;
|
|
223
|
+
let pixelY = 0;
|
|
224
|
+
let pixelZ = 0;
|
|
225
|
+
if ("detail" in event) {
|
|
226
|
+
pixelY = event.detail;
|
|
227
|
+
}
|
|
228
|
+
if ("wheelDelta" in event) {
|
|
229
|
+
pixelY = -event.wheelDelta / 120;
|
|
230
|
+
}
|
|
231
|
+
if ("wheelDeltaY" in event) {
|
|
232
|
+
pixelY = -event.wheelDeltaY / 120;
|
|
233
|
+
}
|
|
234
|
+
if ("wheelDeltaX" in event) {
|
|
235
|
+
pixelX = -event.wheelDeltaX / 120;
|
|
236
|
+
}
|
|
237
|
+
if ("deltaY" in event) {
|
|
238
|
+
pixelY = event.deltaY;
|
|
239
|
+
}
|
|
240
|
+
if ("deltaX" in event) {
|
|
241
|
+
pixelX = event.deltaX;
|
|
242
|
+
}
|
|
243
|
+
if ((event.deltaMode || 0) === 1) {
|
|
244
|
+
pixelY *= 40;
|
|
245
|
+
pixelX *= 40;
|
|
246
|
+
} else if ((event.deltaMode || 0) === 2) {
|
|
247
|
+
pixelY *= 800;
|
|
248
|
+
pixelX *= 800;
|
|
249
|
+
}
|
|
250
|
+
return { pixelX, pixelY, pixelZ };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// hooks/useWheelHandler.ts
|
|
254
|
+
function useWheelHandler() {
|
|
255
|
+
const scrollAccumulator = useRef(0);
|
|
256
|
+
const lastScrollTime = useRef(0);
|
|
257
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
258
|
+
useEffect(() => {
|
|
259
|
+
const handleWheel = (event) => {
|
|
260
|
+
const state = useScrollStore.getState();
|
|
261
|
+
if (state.isTransitioning || state.isDragging) {
|
|
262
|
+
event.preventDefault();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const normalized = normalizeWheel(event);
|
|
266
|
+
const delta = normalized.pixelY;
|
|
267
|
+
scrollAccumulator.current += delta;
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
if (now - lastScrollTime.current > 200) {
|
|
270
|
+
scrollAccumulator.current = delta;
|
|
271
|
+
}
|
|
272
|
+
lastScrollTime.current = now;
|
|
273
|
+
if (Math.abs(scrollAccumulator.current) >= NAV_THRESHOLDS.WHEEL) {
|
|
274
|
+
const direction = scrollAccumulator.current > 0 ? "down" : "up";
|
|
275
|
+
const intention = {
|
|
276
|
+
type: "navigate",
|
|
277
|
+
direction,
|
|
278
|
+
strength: Math.min(Math.abs(scrollAccumulator.current) / NAV_THRESHOLDS.WHEEL, 1),
|
|
279
|
+
origin: "wheel"
|
|
280
|
+
};
|
|
281
|
+
const handled = useScrollStore.getState().processIntention(intention);
|
|
282
|
+
if (handled) {
|
|
283
|
+
scrollAccumulator.current = 0;
|
|
284
|
+
event.preventDefault();
|
|
285
|
+
} else {
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
window.addEventListener("wheel", handleWheel, { passive: false });
|
|
290
|
+
return () => window.removeEventListener("wheel", handleWheel);
|
|
291
|
+
}, []);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// hooks/useTouchHandler.ts
|
|
295
|
+
import { useRef as useRef2, useEffect as useEffect2 } from "react";
|
|
296
|
+
function useTouchHandler(options = {}) {
|
|
297
|
+
const { enabled = true } = options;
|
|
298
|
+
const touchStart = useRef2(null);
|
|
299
|
+
const touchStartTime = useRef2(0);
|
|
300
|
+
useEffect2(() => {
|
|
301
|
+
if (!enabled) return;
|
|
302
|
+
const handleTouchStart = (e) => {
|
|
303
|
+
touchStart.current = {
|
|
304
|
+
x: e.touches[0].clientX,
|
|
305
|
+
y: e.touches[0].clientY
|
|
306
|
+
};
|
|
307
|
+
touchStartTime.current = Date.now();
|
|
308
|
+
};
|
|
309
|
+
const handleTouchEnd = (e) => {
|
|
310
|
+
if (!touchStart.current) return;
|
|
311
|
+
const touchEnd = {
|
|
312
|
+
x: e.changedTouches[0].clientX,
|
|
313
|
+
y: e.changedTouches[0].clientY
|
|
314
|
+
};
|
|
315
|
+
const deltaY = touchStart.current.y - touchEnd.y;
|
|
316
|
+
const deltaX = touchStart.current.x - touchEnd.x;
|
|
317
|
+
const timeElapsed = Date.now() - touchStartTime.current;
|
|
318
|
+
if (Math.abs(deltaY) > Math.abs(deltaX) && // Vertical
|
|
319
|
+
Math.abs(deltaY) > NAV_THRESHOLDS.TOUCH && // Threshold distancia
|
|
320
|
+
timeElapsed < 800) {
|
|
321
|
+
const direction = deltaY > 0 ? "down" : "up";
|
|
322
|
+
const intention = {
|
|
323
|
+
type: "navigate",
|
|
324
|
+
direction,
|
|
325
|
+
strength: 1,
|
|
326
|
+
// Swipes son intenciones fuertes
|
|
327
|
+
origin: "touch"
|
|
328
|
+
};
|
|
329
|
+
useScrollStore.getState().processIntention(intention);
|
|
330
|
+
}
|
|
331
|
+
touchStart.current = null;
|
|
332
|
+
};
|
|
333
|
+
window.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
334
|
+
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
335
|
+
return () => {
|
|
336
|
+
window.removeEventListener("touchstart", handleTouchStart);
|
|
337
|
+
window.removeEventListener("touchend", handleTouchEnd);
|
|
338
|
+
};
|
|
339
|
+
}, [enabled]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// hooks/useKeyboardHandler.ts
|
|
343
|
+
import { useEffect as useEffect3 } from "react";
|
|
344
|
+
function useKeyboardHandler(options = {}) {
|
|
345
|
+
const { enabled = true, preventDefault = true } = options;
|
|
346
|
+
useEffect3(() => {
|
|
347
|
+
if (!enabled) return;
|
|
348
|
+
const handleKeyDown = (e) => {
|
|
349
|
+
const target = e.target;
|
|
350
|
+
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
let intention = null;
|
|
354
|
+
switch (e.key) {
|
|
355
|
+
case "ArrowDown":
|
|
356
|
+
case "PageDown":
|
|
357
|
+
intention = {
|
|
358
|
+
type: "navigate",
|
|
359
|
+
direction: "down",
|
|
360
|
+
strength: 1,
|
|
361
|
+
origin: "keyboard"
|
|
362
|
+
};
|
|
363
|
+
break;
|
|
364
|
+
case "ArrowUp":
|
|
365
|
+
case "PageUp":
|
|
366
|
+
intention = {
|
|
367
|
+
type: "navigate",
|
|
368
|
+
direction: "up",
|
|
369
|
+
strength: 1,
|
|
370
|
+
origin: "keyboard"
|
|
371
|
+
};
|
|
372
|
+
break;
|
|
373
|
+
case " ":
|
|
374
|
+
intention = {
|
|
375
|
+
type: "navigate",
|
|
376
|
+
direction: e.shiftKey ? "up" : "down",
|
|
377
|
+
strength: 1,
|
|
378
|
+
origin: "keyboard"
|
|
379
|
+
};
|
|
380
|
+
break;
|
|
381
|
+
case "Home":
|
|
382
|
+
useScrollStore.getState().goToView(0);
|
|
383
|
+
if (preventDefault) e.preventDefault();
|
|
384
|
+
return;
|
|
385
|
+
case "End":
|
|
386
|
+
const totalViews = useScrollStore.getState().totalViews;
|
|
387
|
+
useScrollStore.getState().goToView(totalViews - 1);
|
|
388
|
+
if (preventDefault) e.preventDefault();
|
|
389
|
+
return;
|
|
390
|
+
default:
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (intention) {
|
|
394
|
+
if (preventDefault) e.preventDefault();
|
|
395
|
+
useScrollStore.getState().processIntention(intention);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
399
|
+
return () => {
|
|
400
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
401
|
+
};
|
|
402
|
+
}, [enabled, preventDefault]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// hooks/useHashSync.ts
|
|
406
|
+
import { useEffect as useEffect4, useRef as useRef3 } from "react";
|
|
407
|
+
function useHashSync(options = {}) {
|
|
408
|
+
const { enabled = true, pushHistory = false, hashPrefix = "" } = options;
|
|
409
|
+
const hasInitialized = useRef3(false);
|
|
410
|
+
useEffect4(() => {
|
|
411
|
+
if (!enabled) return;
|
|
412
|
+
const unsubscribe = useScrollStore.subscribe(
|
|
413
|
+
(state) => state.activeIndex,
|
|
414
|
+
(activeIndex, prevIndex) => {
|
|
415
|
+
if (!hasInitialized.current) return;
|
|
416
|
+
if (activeIndex === prevIndex) return;
|
|
417
|
+
const views = useScrollStore.getState().views;
|
|
418
|
+
const activeView = views[activeIndex];
|
|
419
|
+
if (activeView) {
|
|
420
|
+
const hash = `#${hashPrefix}${activeView.id}`;
|
|
421
|
+
if (pushHistory) {
|
|
422
|
+
window.history.pushState(null, "", hash);
|
|
423
|
+
} else {
|
|
424
|
+
window.history.replaceState(null, "", hash);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
);
|
|
429
|
+
return () => unsubscribe();
|
|
430
|
+
}, [enabled, pushHistory, hashPrefix]);
|
|
431
|
+
useEffect4(() => {
|
|
432
|
+
if (!enabled) return;
|
|
433
|
+
const handlePopState = () => {
|
|
434
|
+
const hash = window.location.hash.slice(1);
|
|
435
|
+
if (!hash) return;
|
|
436
|
+
const viewId = hashPrefix ? hash.replace(hashPrefix, "") : hash;
|
|
437
|
+
const views = useScrollStore.getState().views;
|
|
438
|
+
const targetIndex = views.findIndex((v) => v.id === viewId);
|
|
439
|
+
if (targetIndex !== -1) {
|
|
440
|
+
useScrollStore.getState().goToView(targetIndex);
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
window.addEventListener("popstate", handlePopState);
|
|
444
|
+
return () => window.removeEventListener("popstate", handlePopState);
|
|
445
|
+
}, [enabled, hashPrefix]);
|
|
446
|
+
useEffect4(() => {
|
|
447
|
+
if (!enabled) return;
|
|
448
|
+
const checkAndNavigate = () => {
|
|
449
|
+
const state = useScrollStore.getState();
|
|
450
|
+
if (!state.isInitialized || state.views.length === 0) {
|
|
451
|
+
setTimeout(checkAndNavigate, 100);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const hash = window.location.hash.slice(1);
|
|
455
|
+
if (!hash) {
|
|
456
|
+
hasInitialized.current = true;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const viewId = hashPrefix ? hash.replace(hashPrefix, "") : hash;
|
|
460
|
+
const targetIndex = state.views.findIndex((v) => v.id === viewId);
|
|
461
|
+
if (targetIndex !== -1 && targetIndex !== state.activeIndex) {
|
|
462
|
+
state.goToView(targetIndex);
|
|
463
|
+
}
|
|
464
|
+
hasInitialized.current = true;
|
|
465
|
+
};
|
|
466
|
+
checkAndNavigate();
|
|
467
|
+
}, [enabled, hashPrefix]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// hooks/useDragHandler.ts
|
|
471
|
+
import { useRef as useRef4, useEffect as useEffect5, useCallback, useState } from "react";
|
|
472
|
+
var DRAG_THRESHOLD = 50;
|
|
473
|
+
var VELOCITY_THRESHOLD = 0.5;
|
|
474
|
+
var RESISTANCE_FACTOR = 0.4;
|
|
475
|
+
function useDragHandler(options = {}) {
|
|
476
|
+
const { enabled = true, onDragUpdate, onDragEnd } = options;
|
|
477
|
+
const [dragState, setDragState] = useState({
|
|
478
|
+
isDragging: false,
|
|
479
|
+
dragOffset: 0,
|
|
480
|
+
dragDirection: null
|
|
481
|
+
});
|
|
482
|
+
const touchStartRef = useRef4(null);
|
|
483
|
+
const lastMoveRef = useRef4(null);
|
|
484
|
+
const rafRef = useRef4(null);
|
|
485
|
+
const updateDragState = useCallback((newState) => {
|
|
486
|
+
setDragState((prev) => {
|
|
487
|
+
const updated = { ...prev, ...newState };
|
|
488
|
+
onDragUpdate?.(updated);
|
|
489
|
+
return updated;
|
|
490
|
+
});
|
|
491
|
+
}, [onDragUpdate]);
|
|
492
|
+
useEffect5(() => {
|
|
493
|
+
if (!enabled) return;
|
|
494
|
+
const handleTouchStart = (e) => {
|
|
495
|
+
const target = e.target;
|
|
496
|
+
if (target.closest('[data-scrollable="true"]')) return;
|
|
497
|
+
touchStartRef.current = {
|
|
498
|
+
y: e.touches[0].clientY,
|
|
499
|
+
time: Date.now()
|
|
500
|
+
};
|
|
501
|
+
lastMoveRef.current = touchStartRef.current;
|
|
502
|
+
useScrollStore.getState().setDragging(true);
|
|
503
|
+
updateDragState({ isDragging: true, dragOffset: 0, dragDirection: null });
|
|
504
|
+
};
|
|
505
|
+
const handleTouchMove = (e) => {
|
|
506
|
+
if (!touchStartRef.current) return;
|
|
507
|
+
const currentY = e.touches[0].clientY;
|
|
508
|
+
const deltaY = touchStartRef.current.y - currentY;
|
|
509
|
+
const viewportHeight = window.innerHeight;
|
|
510
|
+
let offset = deltaY / viewportHeight;
|
|
511
|
+
const store = useScrollStore.getState();
|
|
512
|
+
const atStart = store.activeIndex === 0 && deltaY < 0;
|
|
513
|
+
const atEnd = store.activeIndex === store.totalViews - 1 && deltaY > 0;
|
|
514
|
+
if (atStart || atEnd) {
|
|
515
|
+
offset = offset * RESISTANCE_FACTOR;
|
|
516
|
+
}
|
|
517
|
+
offset = Math.max(-1, Math.min(1, offset));
|
|
518
|
+
lastMoveRef.current = { y: currentY, time: Date.now() };
|
|
519
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
520
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
521
|
+
updateDragState({
|
|
522
|
+
dragOffset: offset,
|
|
523
|
+
dragDirection: deltaY > 0 ? "down" : deltaY < 0 ? "up" : null
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
const handleTouchEnd = (e) => {
|
|
528
|
+
if (!touchStartRef.current || !lastMoveRef.current) {
|
|
529
|
+
updateDragState({ isDragging: false, dragOffset: 0, dragDirection: null });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const endY = e.changedTouches[0].clientY;
|
|
533
|
+
const deltaY = touchStartRef.current.y - endY;
|
|
534
|
+
const timeDelta = Date.now() - lastMoveRef.current.time;
|
|
535
|
+
const velocity = timeDelta > 0 ? Math.abs(deltaY) / timeDelta : 0;
|
|
536
|
+
const store = useScrollStore.getState();
|
|
537
|
+
const atStart = store.activeIndex === 0;
|
|
538
|
+
const atEnd = store.activeIndex === store.totalViews - 1;
|
|
539
|
+
const exceedsThreshold = Math.abs(deltaY) > DRAG_THRESHOLD;
|
|
540
|
+
const hasVelocity = velocity > VELOCITY_THRESHOLD;
|
|
541
|
+
const direction = deltaY > 0 ? "down" : "up";
|
|
542
|
+
const canNavigate = direction === "down" && !atEnd || direction === "up" && !atStart;
|
|
543
|
+
const shouldNavigate = canNavigate && (exceedsThreshold || hasVelocity);
|
|
544
|
+
onDragEnd?.(shouldNavigate, direction);
|
|
545
|
+
if (shouldNavigate) {
|
|
546
|
+
store.processIntention({
|
|
547
|
+
type: "navigate",
|
|
548
|
+
direction,
|
|
549
|
+
strength: Math.min(1, velocity / VELOCITY_THRESHOLD),
|
|
550
|
+
origin: "touch"
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
touchStartRef.current = null;
|
|
554
|
+
lastMoveRef.current = null;
|
|
555
|
+
useScrollStore.getState().setDragging(false);
|
|
556
|
+
updateDragState({ isDragging: false, dragOffset: 0, dragDirection: null });
|
|
557
|
+
};
|
|
558
|
+
const handleTouchCancel = () => {
|
|
559
|
+
touchStartRef.current = null;
|
|
560
|
+
lastMoveRef.current = null;
|
|
561
|
+
useScrollStore.getState().setDragging(false);
|
|
562
|
+
updateDragState({ isDragging: false, dragOffset: 0, dragDirection: null });
|
|
563
|
+
};
|
|
564
|
+
window.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
565
|
+
window.addEventListener("touchmove", handleTouchMove, { passive: true });
|
|
566
|
+
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
567
|
+
window.addEventListener("touchcancel", handleTouchCancel, { passive: true });
|
|
568
|
+
return () => {
|
|
569
|
+
window.removeEventListener("touchstart", handleTouchStart);
|
|
570
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
571
|
+
window.removeEventListener("touchend", handleTouchEnd);
|
|
572
|
+
window.removeEventListener("touchcancel", handleTouchCancel);
|
|
573
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
574
|
+
};
|
|
575
|
+
}, [enabled, updateDragState, onDragEnd]);
|
|
576
|
+
return dragState;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// hooks/useFocusManagement.ts
|
|
580
|
+
import { useEffect as useEffect6, useRef as useRef5 } from "react";
|
|
581
|
+
function useFocusManagement(options = {}) {
|
|
582
|
+
const { enabled = true, focusDelay = 100 } = options;
|
|
583
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
584
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
585
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
586
|
+
const prevIndexRef = useRef5(activeIndex);
|
|
587
|
+
useEffect6(() => {
|
|
588
|
+
if (!enabled) return;
|
|
589
|
+
if (activeIndex !== prevIndexRef.current && !isTransitioning && activeId) {
|
|
590
|
+
const timer = setTimeout(() => {
|
|
591
|
+
const viewElement = document.getElementById(activeId);
|
|
592
|
+
if (viewElement) {
|
|
593
|
+
if (!viewElement.hasAttribute("tabindex")) {
|
|
594
|
+
viewElement.setAttribute("tabindex", "-1");
|
|
595
|
+
}
|
|
596
|
+
viewElement.focus({ preventScroll: true });
|
|
597
|
+
}
|
|
598
|
+
prevIndexRef.current = activeIndex;
|
|
599
|
+
}, focusDelay);
|
|
600
|
+
return () => clearTimeout(timer);
|
|
601
|
+
}
|
|
602
|
+
}, [activeIndex, activeId, isTransitioning, enabled, focusDelay]);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// hooks/useScrollSystem.ts
|
|
606
|
+
import { useCallback as useCallback2, useMemo } from "react";
|
|
607
|
+
function useScrollSystem() {
|
|
608
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
609
|
+
const globalProgress = useScrollStore((s) => s.globalProgress);
|
|
610
|
+
const isGlobalLocked = useScrollStore((s) => s.isGlobalLocked);
|
|
611
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
612
|
+
const isDragging = useScrollStore((s) => s.isDragging);
|
|
613
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
614
|
+
const totalViews = useScrollStore((s) => s.totalViews);
|
|
615
|
+
const views = useScrollStore((s) => s.views);
|
|
616
|
+
const {
|
|
617
|
+
goToNext: storeNext,
|
|
618
|
+
goToPrevious: storePrev,
|
|
619
|
+
goToView: storeGoTo
|
|
620
|
+
} = useScrollStore();
|
|
621
|
+
const activeView = views[activeIndex];
|
|
622
|
+
const activeViewType = activeView?.type ?? null;
|
|
623
|
+
const activeViewProgress = activeView?.progress ?? 0;
|
|
624
|
+
const canNavigateNext = useScrollStore(selectCanNavigateNext);
|
|
625
|
+
const canNavigatePrevious = useScrollStore(selectCanNavigatePrevious);
|
|
626
|
+
const goToNext = useCallback2(() => {
|
|
627
|
+
if (canNavigateNext) {
|
|
628
|
+
storeNext();
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
}, [canNavigateNext, storeNext]);
|
|
633
|
+
const goToPrev = useCallback2(() => {
|
|
634
|
+
if (canNavigatePrevious) {
|
|
635
|
+
storePrev();
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
return false;
|
|
639
|
+
}, [canNavigatePrevious, storePrev]);
|
|
640
|
+
const getCurrentIndex = useCallback2(() => activeIndex, [activeIndex]);
|
|
641
|
+
const getProgress = useCallback2(() => globalProgress, [globalProgress]);
|
|
642
|
+
const getActiveViewProgress = useCallback2(() => activeViewProgress, [activeViewProgress]);
|
|
643
|
+
const isLocked = useCallback2(() => isGlobalLocked || isTransitioning || !canNavigateNext, [isGlobalLocked, isTransitioning, canNavigateNext]);
|
|
644
|
+
return useMemo(() => ({
|
|
645
|
+
goToNext,
|
|
646
|
+
goToPrev,
|
|
647
|
+
goTo: storeGoTo,
|
|
648
|
+
getCurrentIndex,
|
|
649
|
+
getProgress,
|
|
650
|
+
getActiveViewProgress,
|
|
651
|
+
isLocked,
|
|
652
|
+
canGoNext: canNavigateNext,
|
|
653
|
+
canGoPrev: canNavigatePrevious,
|
|
654
|
+
// Extended Data for Nav/UI
|
|
655
|
+
activeIndex,
|
|
656
|
+
activeId,
|
|
657
|
+
activeViewType,
|
|
658
|
+
totalViews,
|
|
659
|
+
// State Flags (for advanced use)
|
|
660
|
+
isDragging,
|
|
661
|
+
isTransitioning
|
|
662
|
+
}), [
|
|
663
|
+
goToNext,
|
|
664
|
+
goToPrev,
|
|
665
|
+
storeGoTo,
|
|
666
|
+
getCurrentIndex,
|
|
667
|
+
getProgress,
|
|
668
|
+
getActiveViewProgress,
|
|
669
|
+
isLocked,
|
|
670
|
+
canNavigateNext,
|
|
671
|
+
canNavigatePrevious,
|
|
672
|
+
activeIndex,
|
|
673
|
+
activeId,
|
|
674
|
+
activeViewType,
|
|
675
|
+
totalViews,
|
|
676
|
+
isDragging,
|
|
677
|
+
isTransitioning
|
|
678
|
+
]);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// utils/index.ts
|
|
682
|
+
function throttle(fn, limit) {
|
|
683
|
+
let lastCall = 0;
|
|
684
|
+
let timeoutId = null;
|
|
685
|
+
return ((...args) => {
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
const remaining = limit - (now - lastCall);
|
|
688
|
+
if (remaining <= 0) {
|
|
689
|
+
if (timeoutId) {
|
|
690
|
+
clearTimeout(timeoutId);
|
|
691
|
+
timeoutId = null;
|
|
692
|
+
}
|
|
693
|
+
lastCall = now;
|
|
694
|
+
fn(...args);
|
|
695
|
+
} else if (!timeoutId) {
|
|
696
|
+
timeoutId = setTimeout(() => {
|
|
697
|
+
lastCall = Date.now();
|
|
698
|
+
timeoutId = null;
|
|
699
|
+
fn(...args);
|
|
700
|
+
}, remaining);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
function prefersReducedMotion() {
|
|
705
|
+
if (typeof window === "undefined") return false;
|
|
706
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// components/ScrollContainer.tsx
|
|
710
|
+
import { jsx } from "react/jsx-runtime";
|
|
711
|
+
function ScrollContainer({
|
|
712
|
+
children,
|
|
713
|
+
className = "",
|
|
714
|
+
transitionDuration = DEFAULT_TRANSITION_DURATION,
|
|
715
|
+
transitionEasing = DEFAULT_TRANSITION_EASING,
|
|
716
|
+
onViewChange,
|
|
717
|
+
onInitialized,
|
|
718
|
+
// Deep Linking options
|
|
719
|
+
enableHashSync = false,
|
|
720
|
+
hashPushHistory = false,
|
|
721
|
+
hashPrefix = "",
|
|
722
|
+
// Accessibility
|
|
723
|
+
respectReducedMotion = true,
|
|
724
|
+
enableFocusManagement = true,
|
|
725
|
+
// Touch Physics
|
|
726
|
+
enableDragPhysics = false,
|
|
727
|
+
// Layout
|
|
728
|
+
orientation = "vertical"
|
|
729
|
+
}) {
|
|
730
|
+
const containerRef = useRef6(null);
|
|
731
|
+
const isBrowser = typeof window !== "undefined";
|
|
732
|
+
const [reducedMotion, setReducedMotion] = useState2(false);
|
|
733
|
+
useEffect7(() => {
|
|
734
|
+
if (!isBrowser || !respectReducedMotion) return;
|
|
735
|
+
setReducedMotion(prefersReducedMotion());
|
|
736
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
737
|
+
const handler = (e) => setReducedMotion(e.matches);
|
|
738
|
+
mediaQuery.addEventListener("change", handler);
|
|
739
|
+
return () => mediaQuery.removeEventListener("change", handler);
|
|
740
|
+
}, [respectReducedMotion, isBrowser]);
|
|
741
|
+
const effectiveDuration = reducedMotion ? 0 : transitionDuration;
|
|
742
|
+
const { initialize, endTransition } = useScrollStore();
|
|
743
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
744
|
+
const totalViews = useScrollStore((s) => s.totalViews);
|
|
745
|
+
const isInitialized = useScrollStore((s) => s.isInitialized);
|
|
746
|
+
const prevIndexRef = useRef6(activeIndex);
|
|
747
|
+
useWheelHandler();
|
|
748
|
+
useTouchHandler({ enabled: !enableDragPhysics });
|
|
749
|
+
useKeyboardHandler();
|
|
750
|
+
const dragState = useDragHandler({
|
|
751
|
+
enabled: enableDragPhysics && !reducedMotion
|
|
752
|
+
});
|
|
753
|
+
useHashSync({
|
|
754
|
+
enabled: enableHashSync,
|
|
755
|
+
pushHistory: hashPushHistory,
|
|
756
|
+
hashPrefix
|
|
757
|
+
});
|
|
758
|
+
useFocusManagement({ enabled: enableFocusManagement });
|
|
759
|
+
useEffect7(() => {
|
|
760
|
+
const timer = setTimeout(() => {
|
|
761
|
+
initialize();
|
|
762
|
+
onInitialized?.();
|
|
763
|
+
}, 50);
|
|
764
|
+
return () => clearTimeout(timer);
|
|
765
|
+
}, [initialize, onInitialized]);
|
|
766
|
+
useEffect7(() => {
|
|
767
|
+
if (prevIndexRef.current !== activeIndex) {
|
|
768
|
+
onViewChange?.(prevIndexRef.current, activeIndex);
|
|
769
|
+
const timer = setTimeout(() => {
|
|
770
|
+
endTransition();
|
|
771
|
+
}, effectiveDuration);
|
|
772
|
+
prevIndexRef.current = activeIndex;
|
|
773
|
+
return () => clearTimeout(timer);
|
|
774
|
+
}
|
|
775
|
+
}, [activeIndex, effectiveDuration, onViewChange, endTransition]);
|
|
776
|
+
const wrapperStyle = useMemo2(() => {
|
|
777
|
+
const baseOffset = activeIndex * 100;
|
|
778
|
+
const dragOffset = dragState.isDragging ? dragState.dragOffset * 100 : 0;
|
|
779
|
+
const transformAxis = orientation === "horizontal" ? "X" : "Y";
|
|
780
|
+
const sizeUnit = orientation === "horizontal" ? "vw" : "vh";
|
|
781
|
+
return {
|
|
782
|
+
transform: `translate${transformAxis}(-${baseOffset + dragOffset}${sizeUnit})`,
|
|
783
|
+
transition: dragState.isDragging ? "none" : effectiveDuration > 0 ? `transform ${effectiveDuration}ms ${transitionEasing}` : "none",
|
|
784
|
+
height: "100%",
|
|
785
|
+
width: "100%",
|
|
786
|
+
display: orientation === "horizontal" ? "flex" : "block",
|
|
787
|
+
flexDirection: orientation === "horizontal" ? "row" : void 0
|
|
788
|
+
};
|
|
789
|
+
}, [activeIndex, effectiveDuration, transitionEasing, dragState, orientation]);
|
|
790
|
+
return /* @__PURE__ */ jsx(
|
|
791
|
+
"div",
|
|
792
|
+
{
|
|
793
|
+
ref: containerRef,
|
|
794
|
+
className: `scroll-container fixed inset-0 overflow-hidden w-screen h-screen ${className}`,
|
|
795
|
+
role: "main",
|
|
796
|
+
"aria-label": "Scroll container",
|
|
797
|
+
children: /* @__PURE__ */ jsx("div", { className: "scroll-wrapper", style: wrapperStyle, children })
|
|
798
|
+
}
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// components/FullView.tsx
|
|
803
|
+
import { useMemo as useMemo3 } from "react";
|
|
804
|
+
|
|
805
|
+
// hooks/useViewRegistration.ts
|
|
806
|
+
import { useEffect as useEffect8, useRef as useRef7 } from "react";
|
|
807
|
+
function useViewRegistration({
|
|
808
|
+
config,
|
|
809
|
+
onActivate,
|
|
810
|
+
onDeactivate,
|
|
811
|
+
onEnterStart,
|
|
812
|
+
onEnterEnd,
|
|
813
|
+
onExitStart,
|
|
814
|
+
onExitEnd
|
|
815
|
+
}) {
|
|
816
|
+
const registerView = useScrollStore((s) => s.registerView);
|
|
817
|
+
const unregisterView = useScrollStore((s) => s.unregisterView);
|
|
818
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
819
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
820
|
+
const callbacksRef = useRef7({
|
|
821
|
+
onActivate,
|
|
822
|
+
onDeactivate,
|
|
823
|
+
onEnterStart,
|
|
824
|
+
onEnterEnd,
|
|
825
|
+
onExitStart,
|
|
826
|
+
onExitEnd
|
|
827
|
+
});
|
|
828
|
+
callbacksRef.current = {
|
|
829
|
+
onActivate,
|
|
830
|
+
onDeactivate,
|
|
831
|
+
onEnterStart,
|
|
832
|
+
onEnterEnd,
|
|
833
|
+
onExitStart,
|
|
834
|
+
onExitEnd
|
|
835
|
+
};
|
|
836
|
+
const wasActiveRef = useRef7(false);
|
|
837
|
+
const wasTransitioningRef = useRef7(false);
|
|
838
|
+
const configRef = useRef7(config);
|
|
839
|
+
if (JSON.stringify(config) !== JSON.stringify(configRef.current)) {
|
|
840
|
+
configRef.current = config;
|
|
841
|
+
}
|
|
842
|
+
useEffect8(() => {
|
|
843
|
+
const currentConfig = configRef.current;
|
|
844
|
+
registerView(currentConfig);
|
|
845
|
+
return () => unregisterView(currentConfig.id);
|
|
846
|
+
}, [registerView, unregisterView, configRef.current]);
|
|
847
|
+
const viewState = useScrollStore((s) => s.views.find((v) => v.id === config.id));
|
|
848
|
+
const isActive = activeId === config.id;
|
|
849
|
+
useEffect8(() => {
|
|
850
|
+
if (isActive && !wasActiveRef.current) {
|
|
851
|
+
callbacksRef.current.onActivate?.();
|
|
852
|
+
} else if (!isActive && wasActiveRef.current) {
|
|
853
|
+
callbacksRef.current.onDeactivate?.();
|
|
854
|
+
}
|
|
855
|
+
wasActiveRef.current = isActive;
|
|
856
|
+
}, [isActive]);
|
|
857
|
+
useEffect8(() => {
|
|
858
|
+
const callbacks = callbacksRef.current;
|
|
859
|
+
const wasActive = wasActiveRef.current;
|
|
860
|
+
const wasTransitioning = wasTransitioningRef.current;
|
|
861
|
+
if (isTransitioning && !wasTransitioning) {
|
|
862
|
+
if (isActive && !wasActive) {
|
|
863
|
+
callbacks.onEnterStart?.();
|
|
864
|
+
} else if (!isActive && wasActive) {
|
|
865
|
+
callbacks.onExitStart?.();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (!isTransitioning && wasTransitioning) {
|
|
869
|
+
if (isActive) {
|
|
870
|
+
callbacks.onEnterEnd?.();
|
|
871
|
+
} else if (wasActive && !isActive) {
|
|
872
|
+
callbacks.onExitEnd?.();
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
wasTransitioningRef.current = isTransitioning;
|
|
876
|
+
}, [isTransitioning, isActive]);
|
|
877
|
+
return {
|
|
878
|
+
isActive,
|
|
879
|
+
viewState,
|
|
880
|
+
index: viewState?.index ?? -1,
|
|
881
|
+
scrollProgress: viewState?.progress ?? 0,
|
|
882
|
+
navigation: viewState?.navigation ?? "unlocked"
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// components/FullView.tsx
|
|
887
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
888
|
+
function FullView({
|
|
889
|
+
id,
|
|
890
|
+
children,
|
|
891
|
+
className = "",
|
|
892
|
+
meta,
|
|
893
|
+
onActivate,
|
|
894
|
+
onDeactivate,
|
|
895
|
+
onEnterStart,
|
|
896
|
+
onEnterEnd,
|
|
897
|
+
onExitStart,
|
|
898
|
+
onExitEnd
|
|
899
|
+
}) {
|
|
900
|
+
const config = useMemo3(
|
|
901
|
+
() => ({
|
|
902
|
+
id,
|
|
903
|
+
type: "full",
|
|
904
|
+
meta
|
|
905
|
+
}),
|
|
906
|
+
[id, meta]
|
|
907
|
+
);
|
|
908
|
+
const { isActive, index } = useViewRegistration({
|
|
909
|
+
config,
|
|
910
|
+
onActivate,
|
|
911
|
+
onDeactivate,
|
|
912
|
+
onEnterStart,
|
|
913
|
+
onEnterEnd,
|
|
914
|
+
onExitStart,
|
|
915
|
+
onExitEnd
|
|
916
|
+
});
|
|
917
|
+
return /* @__PURE__ */ jsx2(
|
|
918
|
+
"section",
|
|
919
|
+
{
|
|
920
|
+
id,
|
|
921
|
+
className: `
|
|
922
|
+
full-view
|
|
923
|
+
relative
|
|
924
|
+
w-full h-screen
|
|
925
|
+
overflow-hidden
|
|
926
|
+
${isActive ? "is-active" : ""}
|
|
927
|
+
${className}
|
|
928
|
+
`,
|
|
929
|
+
"data-view-type": "full",
|
|
930
|
+
"data-view-index": index,
|
|
931
|
+
"data-view-active": isActive,
|
|
932
|
+
"aria-hidden": !isActive,
|
|
933
|
+
children
|
|
934
|
+
}
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// hooks/useMetricsReporter.ts
|
|
939
|
+
import { useRef as useRef8, useCallback as useCallback3, useEffect as useEffect9, useMemo as useMemo4 } from "react";
|
|
940
|
+
var SCROLL_THROTTLE_MS = 66;
|
|
941
|
+
function useMetricsReporter({
|
|
942
|
+
id,
|
|
943
|
+
isActive,
|
|
944
|
+
scrollDirection,
|
|
945
|
+
onScrollProgress,
|
|
946
|
+
throttleMs = SCROLL_THROTTLE_MS
|
|
947
|
+
}) {
|
|
948
|
+
const scrollRef = useRef8(null);
|
|
949
|
+
const updateMetrics = useScrollStore((s) => s.updateViewMetrics);
|
|
950
|
+
const measureAndReport = useCallback3(() => {
|
|
951
|
+
const el = scrollRef.current;
|
|
952
|
+
if (!el) return;
|
|
953
|
+
const metrics = {
|
|
954
|
+
scrollHeight: scrollDirection === "vertical" ? el.scrollHeight : el.scrollWidth,
|
|
955
|
+
clientHeight: scrollDirection === "vertical" ? el.clientHeight : el.clientWidth,
|
|
956
|
+
scrollTop: scrollDirection === "vertical" ? el.scrollTop : el.scrollLeft
|
|
957
|
+
};
|
|
958
|
+
updateMetrics(id, metrics);
|
|
959
|
+
if (onScrollProgress) {
|
|
960
|
+
const max = metrics.scrollHeight - metrics.clientHeight;
|
|
961
|
+
const progress = max > 0 ? metrics.scrollTop / max : 1;
|
|
962
|
+
onScrollProgress(progress);
|
|
963
|
+
}
|
|
964
|
+
}, [id, scrollDirection, updateMetrics, onScrollProgress]);
|
|
965
|
+
const throttledMeasure = useMemo4(
|
|
966
|
+
() => throttle(measureAndReport, throttleMs),
|
|
967
|
+
[measureAndReport, throttleMs]
|
|
968
|
+
);
|
|
969
|
+
useEffect9(() => {
|
|
970
|
+
const el = scrollRef.current;
|
|
971
|
+
if (!el) return;
|
|
972
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
973
|
+
window.requestAnimationFrame(measureAndReport);
|
|
974
|
+
});
|
|
975
|
+
resizeObserver.observe(el);
|
|
976
|
+
Array.from(el.children).forEach((child) => resizeObserver.observe(child));
|
|
977
|
+
el.addEventListener("scroll", throttledMeasure, { passive: true });
|
|
978
|
+
measureAndReport();
|
|
979
|
+
return () => {
|
|
980
|
+
resizeObserver.disconnect();
|
|
981
|
+
el.removeEventListener("scroll", throttledMeasure);
|
|
982
|
+
};
|
|
983
|
+
}, [measureAndReport, throttledMeasure]);
|
|
984
|
+
useEffect9(() => {
|
|
985
|
+
if (isActive) {
|
|
986
|
+
measureAndReport();
|
|
987
|
+
}
|
|
988
|
+
}, [isActive, measureAndReport]);
|
|
989
|
+
return { scrollRef, measureAndReport };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// components/ScrollLockedView.tsx
|
|
993
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
994
|
+
function ScrollLockedView({
|
|
995
|
+
id,
|
|
996
|
+
children,
|
|
997
|
+
className = "",
|
|
998
|
+
scrollDirection = "vertical",
|
|
999
|
+
scrollEndThreshold = 0.99,
|
|
1000
|
+
onScrollProgress,
|
|
1001
|
+
onActivate,
|
|
1002
|
+
onDeactivate,
|
|
1003
|
+
onEnterStart,
|
|
1004
|
+
onEnterEnd,
|
|
1005
|
+
onExitStart,
|
|
1006
|
+
onExitEnd
|
|
1007
|
+
}) {
|
|
1008
|
+
const { isActive, index } = useViewRegistration({
|
|
1009
|
+
config: {
|
|
1010
|
+
id,
|
|
1011
|
+
type: "scroll-locked",
|
|
1012
|
+
scrollDirection,
|
|
1013
|
+
scrollEndThreshold
|
|
1014
|
+
},
|
|
1015
|
+
onActivate,
|
|
1016
|
+
onDeactivate,
|
|
1017
|
+
onEnterStart,
|
|
1018
|
+
onEnterEnd,
|
|
1019
|
+
onExitStart,
|
|
1020
|
+
onExitEnd
|
|
1021
|
+
});
|
|
1022
|
+
const { scrollRef } = useMetricsReporter({
|
|
1023
|
+
id,
|
|
1024
|
+
isActive,
|
|
1025
|
+
scrollDirection,
|
|
1026
|
+
onScrollProgress
|
|
1027
|
+
});
|
|
1028
|
+
const scrollClasses = scrollDirection === "vertical" ? "overflow-y-auto overflow-x-hidden" : "overflow-x-auto overflow-y-hidden";
|
|
1029
|
+
return /* @__PURE__ */ jsx3(
|
|
1030
|
+
"section",
|
|
1031
|
+
{
|
|
1032
|
+
id,
|
|
1033
|
+
className: `relative w-full h-screen ${className}`,
|
|
1034
|
+
"data-view-type": "scroll-locked",
|
|
1035
|
+
"data-active": isActive,
|
|
1036
|
+
children: /* @__PURE__ */ jsx3(
|
|
1037
|
+
"div",
|
|
1038
|
+
{
|
|
1039
|
+
ref: scrollRef,
|
|
1040
|
+
className: `w-full h-full ${scrollClasses} no-scrollbar`,
|
|
1041
|
+
style: { scrollbarWidth: "none" },
|
|
1042
|
+
children
|
|
1043
|
+
}
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// components/ControlledView.tsx
|
|
1050
|
+
import { useMemo as useMemo5, useEffect as useEffect10 } from "react";
|
|
1051
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
1052
|
+
function ControlledView({
|
|
1053
|
+
id,
|
|
1054
|
+
children,
|
|
1055
|
+
className = "",
|
|
1056
|
+
scrollDirection = "none",
|
|
1057
|
+
allowInternalScroll = false,
|
|
1058
|
+
canProceed = false,
|
|
1059
|
+
allowGoBack = true,
|
|
1060
|
+
onActivate,
|
|
1061
|
+
onDeactivate,
|
|
1062
|
+
onEnterStart,
|
|
1063
|
+
onEnterEnd,
|
|
1064
|
+
onExitStart,
|
|
1065
|
+
onExitEnd
|
|
1066
|
+
}) {
|
|
1067
|
+
const setExplicitLock = useScrollStore((s) => s.setViewExplicitLock);
|
|
1068
|
+
const config = useMemo5(
|
|
1069
|
+
() => ({
|
|
1070
|
+
id,
|
|
1071
|
+
type: "controlled",
|
|
1072
|
+
scrollDirection,
|
|
1073
|
+
allowInternalScroll,
|
|
1074
|
+
allowGoBack
|
|
1075
|
+
}),
|
|
1076
|
+
[id, scrollDirection, allowInternalScroll, allowGoBack]
|
|
1077
|
+
);
|
|
1078
|
+
const { isActive, index } = useViewRegistration({
|
|
1079
|
+
config,
|
|
1080
|
+
onActivate,
|
|
1081
|
+
onDeactivate,
|
|
1082
|
+
onEnterStart,
|
|
1083
|
+
onEnterEnd,
|
|
1084
|
+
onExitStart,
|
|
1085
|
+
onExitEnd
|
|
1086
|
+
});
|
|
1087
|
+
const { scrollRef } = useMetricsReporter({
|
|
1088
|
+
id,
|
|
1089
|
+
isActive,
|
|
1090
|
+
scrollDirection: allowInternalScroll ? scrollDirection : "none"
|
|
1091
|
+
});
|
|
1092
|
+
useEffect10(() => {
|
|
1093
|
+
const lockState = canProceed ? "unlocked" : "locked";
|
|
1094
|
+
setExplicitLock(id, lockState);
|
|
1095
|
+
}, [id, canProceed, setExplicitLock]);
|
|
1096
|
+
const overflowClasses = useMemo5(() => {
|
|
1097
|
+
if (!allowInternalScroll) return "overflow-hidden";
|
|
1098
|
+
switch (scrollDirection) {
|
|
1099
|
+
case "vertical":
|
|
1100
|
+
return "overflow-y-auto overflow-x-hidden";
|
|
1101
|
+
case "horizontal":
|
|
1102
|
+
return "overflow-x-auto overflow-y-hidden";
|
|
1103
|
+
default:
|
|
1104
|
+
return "overflow-auto";
|
|
1105
|
+
}
|
|
1106
|
+
}, [allowInternalScroll, scrollDirection]);
|
|
1107
|
+
return /* @__PURE__ */ jsx4(
|
|
1108
|
+
"section",
|
|
1109
|
+
{
|
|
1110
|
+
id,
|
|
1111
|
+
className: `relative w-full h-screen ${isActive ? "z-10" : "z-0"} ${className}`,
|
|
1112
|
+
"data-view-type": "controlled",
|
|
1113
|
+
"data-active": isActive,
|
|
1114
|
+
children: /* @__PURE__ */ jsx4(
|
|
1115
|
+
"div",
|
|
1116
|
+
{
|
|
1117
|
+
ref: scrollRef,
|
|
1118
|
+
className: `w-full h-full ${overflowClasses}`,
|
|
1119
|
+
children
|
|
1120
|
+
}
|
|
1121
|
+
)
|
|
1122
|
+
}
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
function useViewControl(viewId) {
|
|
1126
|
+
const setExplicitLock = useScrollStore((s) => s.setViewExplicitLock);
|
|
1127
|
+
const goToNext = useScrollStore((s) => s.goToNext);
|
|
1128
|
+
const goToPrevious = useScrollStore((s) => s.goToPrevious);
|
|
1129
|
+
const goToView = useScrollStore((s) => s.goToView);
|
|
1130
|
+
return useMemo5(
|
|
1131
|
+
() => ({
|
|
1132
|
+
unlock: () => setExplicitLock(viewId, "unlocked"),
|
|
1133
|
+
lock: () => setExplicitLock(viewId, "locked"),
|
|
1134
|
+
// Navegación Programática (Forzada)
|
|
1135
|
+
goNext: () => goToNext(),
|
|
1136
|
+
goPrev: () => goToPrevious(),
|
|
1137
|
+
goTo: (to) => goToView(to)
|
|
1138
|
+
}),
|
|
1139
|
+
[viewId, setExplicitLock, goToNext, goToPrevious, goToView]
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// components/ScrollDebugOverlay.tsx
|
|
1144
|
+
import { jsx as jsx5, jsxs } from "react/jsx-runtime";
|
|
1145
|
+
function ScrollDebugOverlay({
|
|
1146
|
+
position = "bottom-left",
|
|
1147
|
+
visible = true
|
|
1148
|
+
}) {
|
|
1149
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
1150
|
+
const totalViews = useScrollStore((s) => s.totalViews);
|
|
1151
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
1152
|
+
const isGlobalLocked = useScrollStore((s) => s.isGlobalLocked);
|
|
1153
|
+
const isInitialized = useScrollStore((s) => s.isInitialized);
|
|
1154
|
+
const views = useScrollStore((s) => s.views);
|
|
1155
|
+
const activeView = views[activeIndex];
|
|
1156
|
+
if (!visible) return null;
|
|
1157
|
+
const positionClasses = {
|
|
1158
|
+
"top-left": "top-4 left-4",
|
|
1159
|
+
"top-right": "top-4 right-4",
|
|
1160
|
+
"bottom-left": "bottom-4 left-4",
|
|
1161
|
+
"bottom-right": "bottom-4 right-4"
|
|
1162
|
+
};
|
|
1163
|
+
return /* @__PURE__ */ jsxs(
|
|
1164
|
+
"div",
|
|
1165
|
+
{
|
|
1166
|
+
className: `fixed ${positionClasses[position]} z-9999 font-mono text-xs bg-black/90 text-green-400 p-3 rounded-lg border border-green-500/30 backdrop-blur-sm max-w-xs`,
|
|
1167
|
+
style: { pointerEvents: "none" },
|
|
1168
|
+
children: [
|
|
1169
|
+
/* @__PURE__ */ jsxs("div", { className: "text-green-300 font-bold mb-2 flex items-center gap-2", children: [
|
|
1170
|
+
/* @__PURE__ */ jsx5("span", { className: "w-2 h-2 rounded-full bg-green-500 animate-pulse" }),
|
|
1171
|
+
"ScrollSystem Debug"
|
|
1172
|
+
] }),
|
|
1173
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1 mb-3", children: [
|
|
1174
|
+
/* @__PURE__ */ jsx5(Row, { label: "initialized", value: isInitialized }),
|
|
1175
|
+
/* @__PURE__ */ jsx5(Row, { label: "activeIndex", value: `${activeIndex} / ${totalViews - 1}` }),
|
|
1176
|
+
/* @__PURE__ */ jsx5(Row, { label: "transitioning", value: isTransitioning }),
|
|
1177
|
+
/* @__PURE__ */ jsx5(Row, { label: "globalLocked", value: isGlobalLocked })
|
|
1178
|
+
] }),
|
|
1179
|
+
activeView && /* @__PURE__ */ jsxs("div", { className: "border-t border-green-500/20 pt-2 mt-2", children: [
|
|
1180
|
+
/* @__PURE__ */ jsx5("div", { className: "text-green-300/70 mb-1", children: "Active View" }),
|
|
1181
|
+
/* @__PURE__ */ jsx5(Row, { label: "id", value: activeView.id }),
|
|
1182
|
+
/* @__PURE__ */ jsx5(Row, { label: "type", value: activeView.type }),
|
|
1183
|
+
/* @__PURE__ */ jsx5(Row, { label: "capability", value: activeView.capability }),
|
|
1184
|
+
/* @__PURE__ */ jsx5(Row, { label: "navigation", value: activeView.navigation }),
|
|
1185
|
+
/* @__PURE__ */ jsx5(Row, { label: "progress", value: `${(activeView.progress * 100).toFixed(0)}%` }),
|
|
1186
|
+
activeView.explicitLock && /* @__PURE__ */ jsx5(Row, { label: "explicitLock", value: activeView.explicitLock, highlight: true })
|
|
1187
|
+
] }),
|
|
1188
|
+
activeView?.metrics && /* @__PURE__ */ jsxs("div", { className: "border-t border-green-500/20 pt-2 mt-2", children: [
|
|
1189
|
+
/* @__PURE__ */ jsx5("div", { className: "text-green-300/70 mb-1", children: "Metrics" }),
|
|
1190
|
+
/* @__PURE__ */ jsx5(Row, { label: "scrollHeight", value: activeView.metrics.scrollHeight }),
|
|
1191
|
+
/* @__PURE__ */ jsx5(Row, { label: "clientHeight", value: activeView.metrics.clientHeight }),
|
|
1192
|
+
/* @__PURE__ */ jsx5(Row, { label: "scrollTop", value: Math.round(activeView.metrics.scrollTop) })
|
|
1193
|
+
] })
|
|
1194
|
+
]
|
|
1195
|
+
}
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
function Row({
|
|
1199
|
+
label,
|
|
1200
|
+
value,
|
|
1201
|
+
highlight = false
|
|
1202
|
+
}) {
|
|
1203
|
+
const displayValue = typeof value === "boolean" ? value ? "\u2713" : "\u2717" : String(value);
|
|
1204
|
+
const valueColor = typeof value === "boolean" ? value ? "text-green-400" : "text-red-400" : highlight ? "text-yellow-400" : "text-white";
|
|
1205
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex justify-between gap-4", children: [
|
|
1206
|
+
/* @__PURE__ */ jsxs("span", { className: "text-green-500/70", children: [
|
|
1207
|
+
label,
|
|
1208
|
+
":"
|
|
1209
|
+
] }),
|
|
1210
|
+
/* @__PURE__ */ jsx5("span", { className: valueColor, children: displayValue })
|
|
1211
|
+
] });
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// components/AriaLiveRegion.tsx
|
|
1215
|
+
import { useEffect as useEffect11, useState as useState3 } from "react";
|
|
1216
|
+
import { jsx as jsx6 } from "react/jsx-runtime";
|
|
1217
|
+
function AriaLiveRegion({
|
|
1218
|
+
template = "Navigated to section {viewIndex} of {totalViews}",
|
|
1219
|
+
politeness = "polite"
|
|
1220
|
+
}) {
|
|
1221
|
+
const [announcement, setAnnouncement] = useState3("");
|
|
1222
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
1223
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
1224
|
+
const totalViews = useScrollStore((s) => s.totalViews);
|
|
1225
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
1226
|
+
useEffect11(() => {
|
|
1227
|
+
if (!isTransitioning && activeId) {
|
|
1228
|
+
const message = template.replace("{viewIndex}", String(activeIndex + 1)).replace("{viewId}", activeId).replace("{totalViews}", String(totalViews));
|
|
1229
|
+
const timer = setTimeout(() => {
|
|
1230
|
+
setAnnouncement(message);
|
|
1231
|
+
}, 100);
|
|
1232
|
+
return () => clearTimeout(timer);
|
|
1233
|
+
}
|
|
1234
|
+
}, [activeIndex, activeId, totalViews, isTransitioning, template]);
|
|
1235
|
+
return /* @__PURE__ */ jsx6(
|
|
1236
|
+
"div",
|
|
1237
|
+
{
|
|
1238
|
+
role: "status",
|
|
1239
|
+
"aria-live": politeness,
|
|
1240
|
+
"aria-atomic": "true",
|
|
1241
|
+
className: "sr-only",
|
|
1242
|
+
style: {
|
|
1243
|
+
position: "absolute",
|
|
1244
|
+
width: "1px",
|
|
1245
|
+
height: "1px",
|
|
1246
|
+
padding: 0,
|
|
1247
|
+
margin: "-1px",
|
|
1248
|
+
overflow: "hidden",
|
|
1249
|
+
clip: "rect(0, 0, 0, 0)",
|
|
1250
|
+
whiteSpace: "nowrap",
|
|
1251
|
+
border: 0
|
|
1252
|
+
},
|
|
1253
|
+
children: announcement
|
|
1254
|
+
}
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// components/LazyView.tsx
|
|
1259
|
+
import { useMemo as useMemo6 } from "react";
|
|
1260
|
+
import { Fragment, jsx as jsx7 } from "react/jsx-runtime";
|
|
1261
|
+
function LazyView({
|
|
1262
|
+
viewId,
|
|
1263
|
+
buffer = 1,
|
|
1264
|
+
children,
|
|
1265
|
+
placeholder = null
|
|
1266
|
+
}) {
|
|
1267
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
1268
|
+
const views = useScrollStore((s) => s.views);
|
|
1269
|
+
const shouldRender = useMemo6(() => {
|
|
1270
|
+
const viewIndex = views.findIndex((v) => v.id === viewId);
|
|
1271
|
+
if (viewIndex === -1) return false;
|
|
1272
|
+
const minIndex = Math.max(0, activeIndex - buffer);
|
|
1273
|
+
const maxIndex = Math.min(views.length - 1, activeIndex + buffer);
|
|
1274
|
+
return viewIndex >= minIndex && viewIndex <= maxIndex;
|
|
1275
|
+
}, [viewId, views, activeIndex, buffer]);
|
|
1276
|
+
if (!shouldRender) {
|
|
1277
|
+
return /* @__PURE__ */ jsx7(Fragment, { children: placeholder });
|
|
1278
|
+
}
|
|
1279
|
+
return /* @__PURE__ */ jsx7(Fragment, { children });
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// hooks/useNavigation.ts
|
|
1283
|
+
import { useCallback as useCallback4, useMemo as useMemo7 } from "react";
|
|
1284
|
+
function useNavigation() {
|
|
1285
|
+
const goToView = useScrollStore((s) => s.goToView);
|
|
1286
|
+
const goToNextAction = useScrollStore((s) => s.goToNext);
|
|
1287
|
+
const goToPreviousAction = useScrollStore((s) => s.goToPrevious);
|
|
1288
|
+
const setGlobalLock = useScrollStore((s) => s.setGlobalLock);
|
|
1289
|
+
const lockScroll = useCallback4(() => setGlobalLock(true), [setGlobalLock]);
|
|
1290
|
+
const unlockScroll = useCallback4(() => setGlobalLock(false), [setGlobalLock]);
|
|
1291
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
1292
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
1293
|
+
const totalViews = useScrollStore((s) => s.totalViews);
|
|
1294
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
1295
|
+
const isScrollLocked = useScrollStore((s) => s.isGlobalLocked);
|
|
1296
|
+
const canNavigateNext = useScrollStore(selectCanNavigateNext);
|
|
1297
|
+
const canNavigatePrevious = useScrollStore(selectCanNavigatePrevious);
|
|
1298
|
+
const goToNext = useCallback4(() => {
|
|
1299
|
+
return goToNextAction();
|
|
1300
|
+
}, [goToNextAction]);
|
|
1301
|
+
const goToPrevious = useCallback4(() => {
|
|
1302
|
+
return goToPreviousAction();
|
|
1303
|
+
}, [goToPreviousAction]);
|
|
1304
|
+
const isFirstView = activeIndex === 0;
|
|
1305
|
+
const isLastView = activeIndex === totalViews - 1;
|
|
1306
|
+
const progress = totalViews > 1 ? activeIndex / (totalViews - 1) : 0;
|
|
1307
|
+
return useMemo7(
|
|
1308
|
+
() => ({
|
|
1309
|
+
// Acciones de navegación
|
|
1310
|
+
goToView,
|
|
1311
|
+
goToNext,
|
|
1312
|
+
goToPrevious,
|
|
1313
|
+
// Control de bloqueo
|
|
1314
|
+
lockScroll,
|
|
1315
|
+
unlockScroll,
|
|
1316
|
+
// Estado actual
|
|
1317
|
+
activeIndex,
|
|
1318
|
+
activeId,
|
|
1319
|
+
totalViews,
|
|
1320
|
+
// Estados de UI
|
|
1321
|
+
isTransitioning,
|
|
1322
|
+
isScrollLocked,
|
|
1323
|
+
canNavigateNext,
|
|
1324
|
+
canNavigatePrevious,
|
|
1325
|
+
// Utilidades
|
|
1326
|
+
isFirstView,
|
|
1327
|
+
isLastView,
|
|
1328
|
+
progress
|
|
1329
|
+
}),
|
|
1330
|
+
[
|
|
1331
|
+
goToView,
|
|
1332
|
+
goToNext,
|
|
1333
|
+
goToPrevious,
|
|
1334
|
+
lockScroll,
|
|
1335
|
+
unlockScroll,
|
|
1336
|
+
activeIndex,
|
|
1337
|
+
activeId,
|
|
1338
|
+
totalViews,
|
|
1339
|
+
isTransitioning,
|
|
1340
|
+
isScrollLocked,
|
|
1341
|
+
canNavigateNext,
|
|
1342
|
+
canNavigatePrevious,
|
|
1343
|
+
isFirstView,
|
|
1344
|
+
isLastView,
|
|
1345
|
+
progress
|
|
1346
|
+
]
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// hooks/useViewProgress.ts
|
|
1351
|
+
function useViewProgress(viewId) {
|
|
1352
|
+
const progress = useScrollStore(
|
|
1353
|
+
(s) => s.views.find((v) => v.id === viewId)?.progress ?? 0
|
|
1354
|
+
);
|
|
1355
|
+
const navigation = useScrollStore(
|
|
1356
|
+
(s) => s.views.find((v) => v.id === viewId)?.navigation ?? "unlocked"
|
|
1357
|
+
);
|
|
1358
|
+
const isAtStart = progress <= 0.02;
|
|
1359
|
+
const isAtEnd = progress >= 0.99;
|
|
1360
|
+
return {
|
|
1361
|
+
progress,
|
|
1362
|
+
isAtStart,
|
|
1363
|
+
isAtEnd,
|
|
1364
|
+
navigation
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
function useActiveViewProgress() {
|
|
1368
|
+
const progress = useScrollStore(selectActiveViewProgress);
|
|
1369
|
+
const activeView = useScrollStore((s) => s.views[s.activeIndex]);
|
|
1370
|
+
const hasInternalScroll = activeView?.capability === "internal";
|
|
1371
|
+
return {
|
|
1372
|
+
progress,
|
|
1373
|
+
hasInternalScroll,
|
|
1374
|
+
viewType: activeView?.type
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// hooks/useScrollAnalytics.ts
|
|
1379
|
+
import { useEffect as useEffect12, useRef as useRef9, useCallback as useCallback5 } from "react";
|
|
1380
|
+
function useScrollAnalytics(options = {}) {
|
|
1381
|
+
const { onViewEnter, onViewExit, enabled = true } = options;
|
|
1382
|
+
const activeIndex = useScrollStore((s) => s.activeIndex);
|
|
1383
|
+
const activeId = useScrollStore((s) => s.activeId);
|
|
1384
|
+
const isTransitioning = useScrollStore((s) => s.isTransitioning);
|
|
1385
|
+
const enterTimeRef = useRef9(Date.now());
|
|
1386
|
+
const prevIndexRef = useRef9(activeIndex);
|
|
1387
|
+
const prevIdRef = useRef9(activeId);
|
|
1388
|
+
const createAnalytics = useCallback5((viewId, viewIndex, enterTime, isActive, exitTime = null) => ({
|
|
1389
|
+
viewId,
|
|
1390
|
+
viewIndex,
|
|
1391
|
+
enterTime,
|
|
1392
|
+
exitTime,
|
|
1393
|
+
duration: exitTime ? (exitTime - enterTime) / 1e3 : 0,
|
|
1394
|
+
isActive
|
|
1395
|
+
}), []);
|
|
1396
|
+
useEffect12(() => {
|
|
1397
|
+
if (!enabled) return;
|
|
1398
|
+
if (!isTransitioning && (activeIndex !== prevIndexRef.current || activeId !== prevIdRef.current)) {
|
|
1399
|
+
const now = Date.now();
|
|
1400
|
+
if (prevIdRef.current) {
|
|
1401
|
+
const exitAnalytics = createAnalytics(
|
|
1402
|
+
prevIdRef.current,
|
|
1403
|
+
prevIndexRef.current,
|
|
1404
|
+
enterTimeRef.current,
|
|
1405
|
+
false,
|
|
1406
|
+
now
|
|
1407
|
+
);
|
|
1408
|
+
onViewExit?.(exitAnalytics);
|
|
1409
|
+
}
|
|
1410
|
+
if (activeId) {
|
|
1411
|
+
enterTimeRef.current = now;
|
|
1412
|
+
const enterAnalytics = createAnalytics(
|
|
1413
|
+
activeId,
|
|
1414
|
+
activeIndex,
|
|
1415
|
+
now,
|
|
1416
|
+
true
|
|
1417
|
+
);
|
|
1418
|
+
onViewEnter?.(enterAnalytics);
|
|
1419
|
+
}
|
|
1420
|
+
prevIndexRef.current = activeIndex;
|
|
1421
|
+
prevIdRef.current = activeId;
|
|
1422
|
+
}
|
|
1423
|
+
}, [activeIndex, activeId, isTransitioning, enabled, onViewEnter, onViewExit, createAnalytics]);
|
|
1424
|
+
return {
|
|
1425
|
+
currentViewId: activeId,
|
|
1426
|
+
currentViewIndex: activeIndex,
|
|
1427
|
+
viewStartTime: enterTimeRef.current,
|
|
1428
|
+
getTimeInView: () => (Date.now() - enterTimeRef.current) / 1e3
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
export {
|
|
1432
|
+
AriaLiveRegion,
|
|
1433
|
+
ControlledView,
|
|
1434
|
+
DEFAULT_PROGRESS_DEBOUNCE,
|
|
1435
|
+
DEFAULT_TRANSITION_DURATION,
|
|
1436
|
+
DEFAULT_TRANSITION_EASING,
|
|
1437
|
+
FullView,
|
|
1438
|
+
LazyView,
|
|
1439
|
+
NAVIGATION_COOLDOWN,
|
|
1440
|
+
NAV_THRESHOLDS,
|
|
1441
|
+
ScrollContainer,
|
|
1442
|
+
ScrollDebugOverlay,
|
|
1443
|
+
ScrollLockedView,
|
|
1444
|
+
selectActiveView,
|
|
1445
|
+
selectActiveViewProgress,
|
|
1446
|
+
selectCanNavigateNext,
|
|
1447
|
+
selectCanNavigatePrevious,
|
|
1448
|
+
useActiveViewProgress,
|
|
1449
|
+
useDragHandler,
|
|
1450
|
+
useFocusManagement,
|
|
1451
|
+
useHashSync,
|
|
1452
|
+
useKeyboardHandler,
|
|
1453
|
+
useMetricsReporter,
|
|
1454
|
+
useNavigation,
|
|
1455
|
+
useScrollAnalytics,
|
|
1456
|
+
useScrollStore,
|
|
1457
|
+
useScrollSystem,
|
|
1458
|
+
useTouchHandler,
|
|
1459
|
+
useViewControl,
|
|
1460
|
+
useViewProgress,
|
|
1461
|
+
useViewRegistration,
|
|
1462
|
+
useWheelHandler
|
|
1463
|
+
};
|