@jorgemadrid/open-carousel 0.1.0
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 +148 -0
- package/dist/index.cjs +2529 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +618 -0
- package/dist/index.d.ts +618 -0
- package/dist/index.js +2471 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +102 -0
- package/package.json +71 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
Carousel: () => Carousel,
|
|
34
|
+
CarouselArrow: () => CarouselArrow,
|
|
35
|
+
CarouselLoggerInstance: () => CarouselLoggerInstance,
|
|
36
|
+
DEBUG_CONFIG: () => DEBUG_CONFIG,
|
|
37
|
+
FEATURE_FLAGS: () => FEATURE_FLAGS,
|
|
38
|
+
LAYOUT_CONFIG: () => LAYOUT_CONFIG,
|
|
39
|
+
TIMING_CONFIG: () => TIMING_CONFIG,
|
|
40
|
+
VISUAL_CONFIG: () => VISUAL_CONFIG,
|
|
41
|
+
carouselLogger: () => carouselLogger,
|
|
42
|
+
clearLoadingStateCache: () => clearLoadingStateCache,
|
|
43
|
+
createLogger: () => createLogger,
|
|
44
|
+
measureLayoutFromElement: () => measureLayoutFromElement,
|
|
45
|
+
reduce: () => reduce,
|
|
46
|
+
useCarouselCoordinator: () => useCarouselCoordinator,
|
|
47
|
+
useCarouselLayout: () => useCarouselLayout,
|
|
48
|
+
useCarouselNavigation: () => useCarouselNavigation,
|
|
49
|
+
useCarouselPersistence: () => useCarouselPersistence,
|
|
50
|
+
useCarouselTeleport: () => useCarouselTeleport,
|
|
51
|
+
useCarouselVisuals: () => useCarouselVisuals,
|
|
52
|
+
useDraggableScroll: () => useDraggableScroll,
|
|
53
|
+
useLoadingState: () => useLoadingState,
|
|
54
|
+
useScrollCompletion: () => useScrollCompletion
|
|
55
|
+
});
|
|
56
|
+
module.exports = __toCommonJS(index_exports);
|
|
57
|
+
|
|
58
|
+
// src/Carousel.tsx
|
|
59
|
+
var import_react10 = require("react");
|
|
60
|
+
|
|
61
|
+
// src/hooks/useDraggableScroll.ts
|
|
62
|
+
var import_react = require("react");
|
|
63
|
+
var MAX_VELOCITY = 500;
|
|
64
|
+
var MIN_VELOCITY_THRESHOLD = 10;
|
|
65
|
+
var FRICTION = 0.96;
|
|
66
|
+
var VELOCITY_SMOOTHING = 0.15;
|
|
67
|
+
var BOUNCE_DISTANCE = 35;
|
|
68
|
+
var BOUNCE_DURATION = 400;
|
|
69
|
+
var PULL_RESISTANCE = 0.35;
|
|
70
|
+
var MAX_PULL_DISTANCE = 80;
|
|
71
|
+
var SNAP_BACK_DURATION = 250;
|
|
72
|
+
var SNAP_DURATION = 200;
|
|
73
|
+
var SNAP_THRESHOLD = 30;
|
|
74
|
+
function useDraggableScroll({
|
|
75
|
+
infinite = false,
|
|
76
|
+
hasNextPage = false,
|
|
77
|
+
onEndReached,
|
|
78
|
+
cardWidth = 320,
|
|
79
|
+
gap = 24,
|
|
80
|
+
cloneCount = 3,
|
|
81
|
+
friction,
|
|
82
|
+
maxVelocity
|
|
83
|
+
} = {}) {
|
|
84
|
+
const ref = (0, import_react.useRef)(null);
|
|
85
|
+
const [isDragging, setIsDragging] = (0, import_react.useState)(false);
|
|
86
|
+
const isDown = (0, import_react.useRef)(false);
|
|
87
|
+
const startX = (0, import_react.useRef)(0);
|
|
88
|
+
const scrollLeftStart = (0, import_react.useRef)(0);
|
|
89
|
+
const currentPointerId = (0, import_react.useRef)(null);
|
|
90
|
+
const velocity = (0, import_react.useRef)(0);
|
|
91
|
+
const lastTimestamp = (0, import_react.useRef)(0);
|
|
92
|
+
const lastPageX = (0, import_react.useRef)(0);
|
|
93
|
+
const animationFrameId = (0, import_react.useRef)(null);
|
|
94
|
+
const currentPullOffset = (0, import_react.useRef)(0);
|
|
95
|
+
const isPullingEdge = (0, import_react.useRef)(false);
|
|
96
|
+
const startedAtLeftEdge = (0, import_react.useRef)(false);
|
|
97
|
+
const startedAtRightEdge = (0, import_react.useRef)(false);
|
|
98
|
+
const isBouncing = (0, import_react.useRef)(false);
|
|
99
|
+
const lastEndReachedTime = (0, import_react.useRef)(0);
|
|
100
|
+
const stride = cardWidth + gap;
|
|
101
|
+
const cancelAnimation = (0, import_react.useCallback)((force = false) => {
|
|
102
|
+
if (isBouncing.current && !force) return;
|
|
103
|
+
if (animationFrameId.current) {
|
|
104
|
+
cancelAnimationFrame(animationFrameId.current);
|
|
105
|
+
animationFrameId.current = null;
|
|
106
|
+
}
|
|
107
|
+
if (ref.current) {
|
|
108
|
+
ref.current.style.transform = "";
|
|
109
|
+
}
|
|
110
|
+
currentPullOffset.current = 0;
|
|
111
|
+
isPullingEdge.current = false;
|
|
112
|
+
isBouncing.current = false;
|
|
113
|
+
}, []);
|
|
114
|
+
const findNearestSnapPoint = (0, import_react.useCallback)((currentScroll, direction) => {
|
|
115
|
+
if (!ref.current) return currentScroll;
|
|
116
|
+
const container = ref.current;
|
|
117
|
+
const children = Array.from(container.children);
|
|
118
|
+
if (children.length === 0) return currentScroll;
|
|
119
|
+
let nearestPoint = currentScroll;
|
|
120
|
+
let minDistance = Infinity;
|
|
121
|
+
const containerCenter = currentScroll + container.clientWidth / 2;
|
|
122
|
+
children.forEach((child) => {
|
|
123
|
+
const childCenter = child.offsetLeft + child.offsetWidth / 2;
|
|
124
|
+
const distance = Math.abs(childCenter - containerCenter);
|
|
125
|
+
const targetScroll = childCenter - container.clientWidth / 2;
|
|
126
|
+
if (direction !== 0) {
|
|
127
|
+
const isInDirection = direction > 0 ? childCenter < containerCenter : childCenter > containerCenter;
|
|
128
|
+
if (isInDirection && distance < minDistance) {
|
|
129
|
+
minDistance = distance;
|
|
130
|
+
nearestPoint = targetScroll;
|
|
131
|
+
}
|
|
132
|
+
} else if (distance < minDistance) {
|
|
133
|
+
minDistance = distance;
|
|
134
|
+
nearestPoint = targetScroll;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const maxScroll = container.scrollWidth - container.clientWidth;
|
|
138
|
+
return Math.max(0, Math.min(maxScroll, nearestPoint));
|
|
139
|
+
}, []);
|
|
140
|
+
const snapToPosition = (0, import_react.useCallback)((targetScroll) => {
|
|
141
|
+
if (!ref.current) return;
|
|
142
|
+
const el = ref.current;
|
|
143
|
+
const startScroll = el.scrollLeft;
|
|
144
|
+
const distance = targetScroll - startScroll;
|
|
145
|
+
if (Math.abs(distance) < 1) {
|
|
146
|
+
el.style.scrollSnapType = "";
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const startTime = performance.now();
|
|
150
|
+
const snapLoop = () => {
|
|
151
|
+
const elapsed = performance.now() - startTime;
|
|
152
|
+
const progress = Math.min(elapsed / SNAP_DURATION, 1);
|
|
153
|
+
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
154
|
+
el.scrollLeft = startScroll + distance * easeProgress;
|
|
155
|
+
if (progress < 1) {
|
|
156
|
+
animationFrameId.current = requestAnimationFrame(snapLoop);
|
|
157
|
+
} else {
|
|
158
|
+
el.scrollLeft = targetScroll;
|
|
159
|
+
animationFrameId.current = null;
|
|
160
|
+
el.style.scrollSnapType = "";
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
animationFrameId.current = requestAnimationFrame(snapLoop);
|
|
164
|
+
}, []);
|
|
165
|
+
const snapBack = (0, import_react.useCallback)(() => {
|
|
166
|
+
if (!ref.current) return;
|
|
167
|
+
if (isBouncing.current) return;
|
|
168
|
+
const el = ref.current;
|
|
169
|
+
const startOffset = currentPullOffset.current;
|
|
170
|
+
const startTime = performance.now();
|
|
171
|
+
currentPullOffset.current = 0;
|
|
172
|
+
isPullingEdge.current = false;
|
|
173
|
+
if (Math.abs(startOffset) < 1) {
|
|
174
|
+
el.style.transform = "";
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const snapLoop = () => {
|
|
178
|
+
const elapsed = performance.now() - startTime;
|
|
179
|
+
const progress = Math.min(elapsed / SNAP_BACK_DURATION, 1);
|
|
180
|
+
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
181
|
+
const offset = startOffset * (1 - easeProgress);
|
|
182
|
+
el.style.transform = Math.abs(offset) > 0.5 ? `translateX(${offset}px)` : "";
|
|
183
|
+
if (progress < 1) {
|
|
184
|
+
animationFrameId.current = requestAnimationFrame(snapLoop);
|
|
185
|
+
} else {
|
|
186
|
+
el.style.transform = "";
|
|
187
|
+
animationFrameId.current = null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
animationFrameId.current = requestAnimationFrame(snapLoop);
|
|
191
|
+
}, []);
|
|
192
|
+
const triggerBounce = (0, import_react.useCallback)((direction) => {
|
|
193
|
+
if (!ref.current) return;
|
|
194
|
+
if (infinite) return;
|
|
195
|
+
if (direction === "right" && hasNextPage) return;
|
|
196
|
+
cancelAnimation(true);
|
|
197
|
+
isBouncing.current = true;
|
|
198
|
+
const el = ref.current;
|
|
199
|
+
const startTime = performance.now();
|
|
200
|
+
const bounceDirection = direction === "left" ? 1 : -1;
|
|
201
|
+
const bounceLoop = () => {
|
|
202
|
+
const elapsed = performance.now() - startTime;
|
|
203
|
+
const progress = Math.min(elapsed / BOUNCE_DURATION, 1);
|
|
204
|
+
const easeProgress = Math.sin(progress * Math.PI);
|
|
205
|
+
const offset = BOUNCE_DISTANCE * bounceDirection * easeProgress;
|
|
206
|
+
el.style.transform = `translateX(${offset}px)`;
|
|
207
|
+
if (progress < 1) {
|
|
208
|
+
animationFrameId.current = requestAnimationFrame(bounceLoop);
|
|
209
|
+
} else {
|
|
210
|
+
el.style.transform = "";
|
|
211
|
+
animationFrameId.current = null;
|
|
212
|
+
isBouncing.current = false;
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
animationFrameId.current = requestAnimationFrame(bounceLoop);
|
|
216
|
+
}, [cancelAnimation, infinite, hasNextPage]);
|
|
217
|
+
const startMomentumScroll = (0, import_react.useCallback)(() => {
|
|
218
|
+
if (!ref.current) return;
|
|
219
|
+
const el = ref.current;
|
|
220
|
+
const activeFriction = friction ?? FRICTION;
|
|
221
|
+
const activeMaxVelocity = maxVelocity ?? MAX_VELOCITY;
|
|
222
|
+
let currentVel = Math.max(-activeMaxVelocity, Math.min(activeMaxVelocity, velocity.current * 16));
|
|
223
|
+
let lastLoopTime = performance.now();
|
|
224
|
+
const initialDirection = currentVel > 0 ? 1 : currentVel < 0 ? -1 : 0;
|
|
225
|
+
if (Math.abs(currentVel) <= MIN_VELOCITY_THRESHOLD) {
|
|
226
|
+
const snapTarget = findNearestSnapPoint(el.scrollLeft, 0);
|
|
227
|
+
snapToPosition(snapTarget);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
231
|
+
if (el.scrollLeft <= 0 && currentVel > 0) {
|
|
232
|
+
triggerBounce("left");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (el.scrollLeft >= maxScroll && currentVel < 0) {
|
|
236
|
+
triggerBounce("right");
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const momentumLoop = () => {
|
|
240
|
+
if (!ref.current) return;
|
|
241
|
+
const now = performance.now();
|
|
242
|
+
const dt = now - lastLoopTime;
|
|
243
|
+
lastLoopTime = now;
|
|
244
|
+
const frameRatio = Math.min(dt / 16, 3);
|
|
245
|
+
currentVel *= Math.pow(activeFriction, frameRatio);
|
|
246
|
+
const prevScroll = el.scrollLeft;
|
|
247
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
248
|
+
el.scrollLeft = prevScroll - currentVel * frameRatio;
|
|
249
|
+
const newScroll = el.scrollLeft;
|
|
250
|
+
const hitLeft = currentVel > 0 && newScroll <= 0 && prevScroll > 0;
|
|
251
|
+
const hitRight = currentVel < 0 && newScroll >= max && prevScroll < max;
|
|
252
|
+
if (hitLeft) {
|
|
253
|
+
if (!infinite) triggerBounce("left");
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (hitRight) {
|
|
257
|
+
if (!infinite && !hasNextPage) triggerBounce("right");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (Math.abs(currentVel) < SNAP_THRESHOLD) {
|
|
261
|
+
const snapTarget = findNearestSnapPoint(el.scrollLeft, initialDirection);
|
|
262
|
+
snapToPosition(snapTarget);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (Math.abs(currentVel) > 0.5) {
|
|
266
|
+
animationFrameId.current = requestAnimationFrame(momentumLoop);
|
|
267
|
+
} else {
|
|
268
|
+
const snapTarget = findNearestSnapPoint(el.scrollLeft, 0);
|
|
269
|
+
snapToPosition(snapTarget);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
animationFrameId.current = requestAnimationFrame(momentumLoop);
|
|
273
|
+
}, [triggerBounce, findNearestSnapPoint, snapToPosition, infinite, hasNextPage]);
|
|
274
|
+
const endDrag = (0, import_react.useCallback)(() => {
|
|
275
|
+
if (!isDown.current) return;
|
|
276
|
+
isDown.current = false;
|
|
277
|
+
if (ref.current && currentPointerId.current !== null) {
|
|
278
|
+
try {
|
|
279
|
+
ref.current.releasePointerCapture(currentPointerId.current);
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
currentPointerId.current = null;
|
|
284
|
+
if (isPullingEdge.current) {
|
|
285
|
+
snapBack();
|
|
286
|
+
setTimeout(() => setIsDragging(false), 0);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (isDragging) {
|
|
290
|
+
startMomentumScroll();
|
|
291
|
+
}
|
|
292
|
+
setTimeout(() => setIsDragging(false), 0);
|
|
293
|
+
}, [isDragging, startMomentumScroll, snapBack]);
|
|
294
|
+
const onPointerDown = (0, import_react.useCallback)((e) => {
|
|
295
|
+
if (e.pointerType === "touch") {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (ref.current) {
|
|
299
|
+
ref.current.style.scrollSnapType = "none";
|
|
300
|
+
}
|
|
301
|
+
isDown.current = true;
|
|
302
|
+
cancelAnimation();
|
|
303
|
+
window.getSelection()?.removeAllRanges();
|
|
304
|
+
if (ref.current) {
|
|
305
|
+
const el = ref.current;
|
|
306
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
307
|
+
startX.current = e.pageX;
|
|
308
|
+
scrollLeftStart.current = el.scrollLeft;
|
|
309
|
+
lastPageX.current = e.pageX;
|
|
310
|
+
lastTimestamp.current = performance.now();
|
|
311
|
+
velocity.current = 0;
|
|
312
|
+
startedAtLeftEdge.current = el.scrollLeft <= 20;
|
|
313
|
+
startedAtRightEdge.current = el.scrollLeft >= maxScroll - 5;
|
|
314
|
+
}
|
|
315
|
+
}, [cancelAnimation]);
|
|
316
|
+
const onPointerUp = (0, import_react.useCallback)((e) => {
|
|
317
|
+
if (!isDown.current) return;
|
|
318
|
+
if (performance.now() - lastTimestamp.current > 80) {
|
|
319
|
+
velocity.current = 0;
|
|
320
|
+
}
|
|
321
|
+
endDrag();
|
|
322
|
+
}, [endDrag]);
|
|
323
|
+
const onPointerMove = (0, import_react.useCallback)((e) => {
|
|
324
|
+
if (!isDown.current || !ref.current) return;
|
|
325
|
+
e.preventDefault();
|
|
326
|
+
const now = performance.now();
|
|
327
|
+
const pageX = e.pageX;
|
|
328
|
+
const el = ref.current;
|
|
329
|
+
const timeDelta = now - lastTimestamp.current;
|
|
330
|
+
if (timeDelta > 0) {
|
|
331
|
+
const instantVel = (pageX - lastPageX.current) / timeDelta;
|
|
332
|
+
velocity.current = velocity.current * (1 - VELOCITY_SMOOTHING) + instantVel * VELOCITY_SMOOTHING;
|
|
333
|
+
lastTimestamp.current = now;
|
|
334
|
+
lastPageX.current = pageX;
|
|
335
|
+
}
|
|
336
|
+
const x = pageX;
|
|
337
|
+
const walk = x - startX.current;
|
|
338
|
+
const intendedScroll = scrollLeftStart.current - walk;
|
|
339
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
340
|
+
const isAtLeftEdge = el.scrollLeft <= 20;
|
|
341
|
+
const isAtRightEdge = el.scrollLeft >= maxScroll - 5;
|
|
342
|
+
const canPullLeft = !infinite && !isBouncing.current && startedAtLeftEdge.current && isAtLeftEdge && intendedScroll < 0;
|
|
343
|
+
const canPullRight = !infinite && !hasNextPage && !isBouncing.current && startedAtRightEdge.current && isAtRightEdge && intendedScroll > maxScroll;
|
|
344
|
+
if (canPullLeft || canPullRight) {
|
|
345
|
+
isPullingEdge.current = true;
|
|
346
|
+
let pullAmount;
|
|
347
|
+
if (canPullLeft) {
|
|
348
|
+
pullAmount = -intendedScroll * PULL_RESISTANCE;
|
|
349
|
+
} else {
|
|
350
|
+
pullAmount = -(intendedScroll - maxScroll) * PULL_RESISTANCE;
|
|
351
|
+
}
|
|
352
|
+
const sign = pullAmount > 0 ? 1 : -1;
|
|
353
|
+
const absPull = Math.min(Math.abs(pullAmount), MAX_PULL_DISTANCE);
|
|
354
|
+
const dampedPull = sign * Math.sqrt(absPull / MAX_PULL_DISTANCE) * MAX_PULL_DISTANCE;
|
|
355
|
+
currentPullOffset.current = dampedPull;
|
|
356
|
+
el.style.transform = `translateX(${dampedPull}px)`;
|
|
357
|
+
if (!isDragging) setIsDragging(true);
|
|
358
|
+
} else {
|
|
359
|
+
if (isPullingEdge.current) {
|
|
360
|
+
el.style.transform = "";
|
|
361
|
+
currentPullOffset.current = 0;
|
|
362
|
+
isPullingEdge.current = false;
|
|
363
|
+
}
|
|
364
|
+
if (Math.abs(walk) > 10) {
|
|
365
|
+
if (!isDragging) {
|
|
366
|
+
setIsDragging(true);
|
|
367
|
+
try {
|
|
368
|
+
el.setPointerCapture(e.pointerId);
|
|
369
|
+
currentPointerId.current = e.pointerId;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
el.scrollLeft = intendedScroll;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}, [isDragging, endDrag, infinite, hasNextPage]);
|
|
377
|
+
const onClickCapture = (0, import_react.useCallback)((e) => {
|
|
378
|
+
if (isDragging) {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
e.stopPropagation();
|
|
381
|
+
}
|
|
382
|
+
}, [isDragging]);
|
|
383
|
+
(0, import_react.useEffect)(() => {
|
|
384
|
+
const handleWindowPointerUp = (e) => {
|
|
385
|
+
if (isDown.current) {
|
|
386
|
+
if (e.target !== ref.current && !ref.current?.contains(e.target)) {
|
|
387
|
+
endDrag();
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
window.addEventListener("pointerup", handleWindowPointerUp);
|
|
392
|
+
window.addEventListener("blur", endDrag);
|
|
393
|
+
return () => {
|
|
394
|
+
window.removeEventListener("pointerup", handleWindowPointerUp);
|
|
395
|
+
window.removeEventListener("blur", endDrag);
|
|
396
|
+
};
|
|
397
|
+
}, [endDrag]);
|
|
398
|
+
(0, import_react.useEffect)(() => cancelAnimation, [cancelAnimation]);
|
|
399
|
+
const adjustScroll = (0, import_react.useCallback)((delta) => {
|
|
400
|
+
scrollLeftStart.current += delta;
|
|
401
|
+
}, []);
|
|
402
|
+
(0, import_react.useEffect)(() => {
|
|
403
|
+
const el = ref.current;
|
|
404
|
+
if (!el) return;
|
|
405
|
+
const handleScroll = () => {
|
|
406
|
+
if (onEndReached) {
|
|
407
|
+
const scrollLeft = el.scrollLeft;
|
|
408
|
+
const scrollWidth = el.scrollWidth;
|
|
409
|
+
const clientWidth = el.clientWidth;
|
|
410
|
+
const distToEnd = scrollWidth - (scrollLeft + clientWidth);
|
|
411
|
+
const threshold = 2 * clientWidth;
|
|
412
|
+
if (distToEnd < threshold) {
|
|
413
|
+
const now = performance.now();
|
|
414
|
+
if (now - lastEndReachedTime.current > 1e3) {
|
|
415
|
+
onEndReached();
|
|
416
|
+
lastEndReachedTime.current = now;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
422
|
+
handleScroll();
|
|
423
|
+
return () => {
|
|
424
|
+
el.removeEventListener("scroll", handleScroll);
|
|
425
|
+
};
|
|
426
|
+
}, [onEndReached]);
|
|
427
|
+
(0, import_react.useEffect)(() => {
|
|
428
|
+
const el = ref.current;
|
|
429
|
+
if (!el || infinite) return;
|
|
430
|
+
}, [infinite]);
|
|
431
|
+
return {
|
|
432
|
+
ref,
|
|
433
|
+
isDragging,
|
|
434
|
+
cancelMomentum: () => cancelAnimation(true),
|
|
435
|
+
adjustScroll,
|
|
436
|
+
events: {
|
|
437
|
+
onPointerDown,
|
|
438
|
+
onPointerUp,
|
|
439
|
+
onPointerMove,
|
|
440
|
+
onLostPointerCapture: onPointerUp,
|
|
441
|
+
onClickCapture,
|
|
442
|
+
onDragStart: (e) => e.preventDefault()
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/hooks/useLoadingState.ts
|
|
448
|
+
var import_react2 = require("react");
|
|
449
|
+
var seenResources = /* @__PURE__ */ new Set();
|
|
450
|
+
function useLoadingState({
|
|
451
|
+
cacheKey,
|
|
452
|
+
skeletonDelay = 500,
|
|
453
|
+
fallbackTimeout = 3e3,
|
|
454
|
+
startReady = false
|
|
455
|
+
} = {}) {
|
|
456
|
+
const wasCached = cacheKey ? seenResources.has(cacheKey) : false;
|
|
457
|
+
const [isReady, setIsReady] = (0, import_react2.useState)(startReady || wasCached);
|
|
458
|
+
const [showSkeleton, setShowSkeleton] = (0, import_react2.useState)(false);
|
|
459
|
+
const [isInstant] = (0, import_react2.useState)(wasCached);
|
|
460
|
+
const mountedRef = (0, import_react2.useRef)(true);
|
|
461
|
+
const readyFiredRef = (0, import_react2.useRef)(startReady || wasCached);
|
|
462
|
+
const skeletonTimerRef = (0, import_react2.useRef)(null);
|
|
463
|
+
const fallbackTimerRef = (0, import_react2.useRef)(null);
|
|
464
|
+
const markReady = (0, import_react2.useCallback)(() => {
|
|
465
|
+
if (readyFiredRef.current) return;
|
|
466
|
+
readyFiredRef.current = true;
|
|
467
|
+
if (skeletonTimerRef.current) clearTimeout(skeletonTimerRef.current);
|
|
468
|
+
if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
|
|
469
|
+
if (cacheKey) seenResources.add(cacheKey);
|
|
470
|
+
if (mountedRef.current) {
|
|
471
|
+
setIsReady(true);
|
|
472
|
+
setShowSkeleton(false);
|
|
473
|
+
}
|
|
474
|
+
}, [cacheKey]);
|
|
475
|
+
(0, import_react2.useEffect)(() => {
|
|
476
|
+
mountedRef.current = true;
|
|
477
|
+
if (readyFiredRef.current) return;
|
|
478
|
+
skeletonTimerRef.current = setTimeout(() => {
|
|
479
|
+
if (mountedRef.current && !readyFiredRef.current) {
|
|
480
|
+
setShowSkeleton(true);
|
|
481
|
+
}
|
|
482
|
+
}, skeletonDelay);
|
|
483
|
+
fallbackTimerRef.current = setTimeout(() => {
|
|
484
|
+
if (mountedRef.current && !readyFiredRef.current) {
|
|
485
|
+
readyFiredRef.current = true;
|
|
486
|
+
if (cacheKey) seenResources.add(cacheKey);
|
|
487
|
+
setIsReady(true);
|
|
488
|
+
setShowSkeleton(false);
|
|
489
|
+
}
|
|
490
|
+
}, fallbackTimeout);
|
|
491
|
+
return () => {
|
|
492
|
+
mountedRef.current = false;
|
|
493
|
+
if (skeletonTimerRef.current) clearTimeout(skeletonTimerRef.current);
|
|
494
|
+
if (fallbackTimerRef.current) clearTimeout(fallbackTimerRef.current);
|
|
495
|
+
};
|
|
496
|
+
}, [cacheKey, skeletonDelay, fallbackTimeout]);
|
|
497
|
+
return { isReady, showSkeleton, isInstant, markReady };
|
|
498
|
+
}
|
|
499
|
+
function clearLoadingStateCache() {
|
|
500
|
+
seenResources.clear();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/hooks/useCarouselTeleport.ts
|
|
504
|
+
var import_react3 = require("react");
|
|
505
|
+
function useCarouselTeleport({
|
|
506
|
+
containerRef,
|
|
507
|
+
infinite,
|
|
508
|
+
itemsCount,
|
|
509
|
+
cardWidth,
|
|
510
|
+
gap,
|
|
511
|
+
bufferBeforeCount,
|
|
512
|
+
applyVisuals,
|
|
513
|
+
adjustScroll,
|
|
514
|
+
preTeleportClearDelayMs,
|
|
515
|
+
coordinator,
|
|
516
|
+
logger
|
|
517
|
+
}) {
|
|
518
|
+
const isTouchInteraction = (0, import_react3.useRef)(false);
|
|
519
|
+
const scrollEndListenerRef = (0, import_react3.useRef)(null);
|
|
520
|
+
const coordinatorRef = (0, import_react3.useRef)(coordinator);
|
|
521
|
+
const applyVisualsRef = (0, import_react3.useRef)(applyVisuals);
|
|
522
|
+
const adjustScrollRef = (0, import_react3.useRef)(adjustScroll);
|
|
523
|
+
const loggerRef = (0, import_react3.useRef)(logger);
|
|
524
|
+
coordinatorRef.current = coordinator;
|
|
525
|
+
applyVisualsRef.current = applyVisuals;
|
|
526
|
+
adjustScrollRef.current = adjustScroll;
|
|
527
|
+
loggerRef.current = logger;
|
|
528
|
+
(0, import_react3.useEffect)(() => {
|
|
529
|
+
const el = containerRef.current;
|
|
530
|
+
if (!el || !infinite || itemsCount === 0) return;
|
|
531
|
+
let stride = cardWidth + gap;
|
|
532
|
+
if (infinite && el.children.length > 1) {
|
|
533
|
+
const firstChild = el.children[0];
|
|
534
|
+
const secondChild = el.children[1];
|
|
535
|
+
const domStride = secondChild.offsetLeft - firstChild.offsetLeft;
|
|
536
|
+
if (domStride > 0 && Math.abs(domStride - stride) > 1) {
|
|
537
|
+
loggerRef.current?.log("TELEPORT", `Using DOM stride for teleport accuracy`, { calculated: stride, measured: domStride });
|
|
538
|
+
stride = domStride;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const originalSetWidth = itemsCount * stride;
|
|
542
|
+
const bufferBeforeWidth = bufferBeforeCount * stride;
|
|
543
|
+
let rafId = null;
|
|
544
|
+
const performTeleport = (source) => {
|
|
545
|
+
const ctx = coordinatorRef.current.getContext();
|
|
546
|
+
if (ctx.isTeleporting) {
|
|
547
|
+
loggerRef.current?.log("TELEPORT", `Teleport in progress (${source})`);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
if (ctx.pendingTarget !== null && source === "scroll") {
|
|
551
|
+
loggerRef.current?.log("TELEPORT", `Pending target exists (${source})`, { target: ctx.pendingTarget });
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
const currentScroll = el.scrollLeft;
|
|
555
|
+
if (currentScroll >= bufferBeforeWidth + originalSetWidth) {
|
|
556
|
+
const overshoot = currentScroll - bufferBeforeWidth;
|
|
557
|
+
const setsPassed = Math.floor(overshoot / originalSetWidth);
|
|
558
|
+
if (setsPassed > 0) {
|
|
559
|
+
const adjust = setsPassed * originalSetWidth;
|
|
560
|
+
loggerRef.current?.log("TELEPORT", `\u26A1 BACKWARD (${source})`, {
|
|
561
|
+
from: currentScroll.toFixed(0),
|
|
562
|
+
to: (currentScroll - adjust).toFixed(0)
|
|
563
|
+
});
|
|
564
|
+
coordinatorRef.current.transition({ type: "SET_TELEPORTING", value: true });
|
|
565
|
+
if (!isTouchInteraction.current) {
|
|
566
|
+
el.scrollTo({ left: el.scrollLeft, behavior: "auto" });
|
|
567
|
+
}
|
|
568
|
+
const newPos = currentScroll - adjust;
|
|
569
|
+
el.scrollLeft = newPos;
|
|
570
|
+
if (!isTouchInteraction.current) {
|
|
571
|
+
applyVisualsRef.current(el, newPos);
|
|
572
|
+
} else {
|
|
573
|
+
requestAnimationFrame(() => applyVisualsRef.current(el, newPos));
|
|
574
|
+
}
|
|
575
|
+
adjustScrollRef.current(-adjust);
|
|
576
|
+
coordinatorRef.current.transition({ type: "SET_TELEPORTING", value: false });
|
|
577
|
+
coordinatorRef.current.transition({ type: "END_TELEPORT" });
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
} else if (currentScroll < bufferBeforeWidth) {
|
|
581
|
+
loggerRef.current?.log("TELEPORT", `\u26A1 FORWARD (${source})`, {
|
|
582
|
+
from: currentScroll.toFixed(0),
|
|
583
|
+
to: (currentScroll + originalSetWidth).toFixed(0)
|
|
584
|
+
});
|
|
585
|
+
coordinatorRef.current.transition({ type: "SET_TELEPORTING", value: true });
|
|
586
|
+
if (!isTouchInteraction.current) {
|
|
587
|
+
el.scrollTo({ left: el.scrollLeft, behavior: "auto" });
|
|
588
|
+
}
|
|
589
|
+
const newPos = currentScroll + originalSetWidth;
|
|
590
|
+
el.scrollLeft = newPos;
|
|
591
|
+
if (!isTouchInteraction.current) {
|
|
592
|
+
applyVisualsRef.current(el, newPos);
|
|
593
|
+
} else {
|
|
594
|
+
requestAnimationFrame(() => applyVisualsRef.current(el, newPos));
|
|
595
|
+
}
|
|
596
|
+
adjustScrollRef.current(originalSetWidth);
|
|
597
|
+
coordinatorRef.current.transition({ type: "SET_TELEPORTING", value: false });
|
|
598
|
+
coordinatorRef.current.transition({ type: "END_TELEPORT" });
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
};
|
|
603
|
+
const handleScroll = () => {
|
|
604
|
+
if (rafId) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
rafId = requestAnimationFrame(() => {
|
|
608
|
+
const rafStartTime = performance.now();
|
|
609
|
+
applyVisualsRef.current(el);
|
|
610
|
+
const ctx = coordinatorRef.current.getContext();
|
|
611
|
+
if (ctx.isTeleporting || ctx.isPreTeleporting) {
|
|
612
|
+
rafId = null;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (!isTouchInteraction.current) {
|
|
616
|
+
performTeleport("scroll");
|
|
617
|
+
} else {
|
|
618
|
+
loggerRef.current?.log("TELEPORT", "Touch drag active: skipping scroll-teleport");
|
|
619
|
+
}
|
|
620
|
+
rafId = null;
|
|
621
|
+
const rafDuration = performance.now() - rafStartTime;
|
|
622
|
+
if (rafDuration > 5) {
|
|
623
|
+
loggerRef.current?.log("TELEPORT", `RAF took ${rafDuration.toFixed(1)}ms`);
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
};
|
|
627
|
+
const handleScrollEnd = () => {
|
|
628
|
+
if (!isTouchInteraction.current) return;
|
|
629
|
+
if (coordinatorRef.current.getContext().pendingTarget !== null) return;
|
|
630
|
+
const currentScroll = el.scrollLeft;
|
|
631
|
+
let paddingOffset = 0;
|
|
632
|
+
if (el.children.length > 0) {
|
|
633
|
+
paddingOffset = el.children[0].offsetLeft;
|
|
634
|
+
}
|
|
635
|
+
const rawIndex = (currentScroll - paddingOffset) / stride;
|
|
636
|
+
const snapSkew = Math.abs(rawIndex - Math.round(rawIndex));
|
|
637
|
+
if (snapSkew > 0.05) {
|
|
638
|
+
loggerRef.current?.log("TELEPORT", "Skipping teleport - mid-snap detected", {
|
|
639
|
+
rawIndex: rawIndex.toFixed(3),
|
|
640
|
+
snapSkew: snapSkew.toFixed(3)
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
loggerRef.current?.log("TELEPORT", "Mobile scroll settled, checking teleport");
|
|
645
|
+
performTeleport("scrollend");
|
|
646
|
+
};
|
|
647
|
+
const handlePointerDown = (e) => {
|
|
648
|
+
if (e.pointerType === "touch") {
|
|
649
|
+
if (!isTouchInteraction.current) loggerRef.current?.log("TELEPORT", "Switched to TOUCH");
|
|
650
|
+
isTouchInteraction.current = true;
|
|
651
|
+
const SAFETY_THRESHOLD = 500;
|
|
652
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
653
|
+
const isDangerouslyCloseToStart = el.scrollLeft < SAFETY_THRESHOLD;
|
|
654
|
+
const isDangerouslyCloseToEnd = el.scrollLeft > maxScroll - SAFETY_THRESHOLD;
|
|
655
|
+
if (isDangerouslyCloseToStart || isDangerouslyCloseToEnd) {
|
|
656
|
+
loggerRef.current?.log("TELEPORT", "\u26A0\uFE0F SAFETY VALVE TRIGGERED: Teleporting during touch to prevent hitting wall");
|
|
657
|
+
const didTeleport = performTeleport("pointerdown");
|
|
658
|
+
if (didTeleport) {
|
|
659
|
+
loggerRef.current?.log("TELEPORT", "Catch & reset teleport performed (Safety Valve)");
|
|
660
|
+
}
|
|
661
|
+
} else {
|
|
662
|
+
}
|
|
663
|
+
} else {
|
|
664
|
+
if (isTouchInteraction.current) loggerRef.current?.log("TELEPORT", "Switched to MOUSE/PEN");
|
|
665
|
+
isTouchInteraction.current = false;
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
669
|
+
el.addEventListener("scrollend", handleScrollEnd);
|
|
670
|
+
el.addEventListener("pointerdown", handlePointerDown, { passive: true });
|
|
671
|
+
loggerRef.current?.log("TELEPORT", "Hybrid teleport handlers attached", {
|
|
672
|
+
stride,
|
|
673
|
+
originalSetWidth,
|
|
674
|
+
bufferBeforeWidth,
|
|
675
|
+
itemsCount
|
|
676
|
+
});
|
|
677
|
+
return () => {
|
|
678
|
+
el.removeEventListener("scroll", handleScroll);
|
|
679
|
+
el.removeEventListener("scrollend", handleScrollEnd);
|
|
680
|
+
el.removeEventListener("pointerdown", handlePointerDown);
|
|
681
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
682
|
+
loggerRef.current?.log("TELEPORT", "Scroll handlers removed");
|
|
683
|
+
};
|
|
684
|
+
}, [containerRef, infinite, itemsCount, cardWidth, gap, bufferBeforeCount]);
|
|
685
|
+
const preTeleport = (targetScroll) => {
|
|
686
|
+
const el = containerRef.current;
|
|
687
|
+
if (!el || !infinite || itemsCount === 0) return targetScroll;
|
|
688
|
+
let stride = cardWidth + gap;
|
|
689
|
+
if (el.children.length > 1) {
|
|
690
|
+
const firstChild = el.children[0];
|
|
691
|
+
const secondChild = el.children[1];
|
|
692
|
+
const domStride = secondChild.offsetLeft - firstChild.offsetLeft;
|
|
693
|
+
if (domStride > 0 && Math.abs(domStride - stride) > 1) {
|
|
694
|
+
logger?.log("TELEPORT", "preTeleport using DOM stride", { calculated: stride, measured: domStride });
|
|
695
|
+
stride = domStride;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const originalSetWidth = itemsCount * stride;
|
|
699
|
+
const bufferBeforeWidth = bufferBeforeCount * stride;
|
|
700
|
+
const needsLeftPreTeleport = targetScroll < bufferBeforeWidth;
|
|
701
|
+
const needsRightPreTeleport = targetScroll >= bufferBeforeWidth + originalSetWidth;
|
|
702
|
+
if (!needsLeftPreTeleport && !needsRightPreTeleport) {
|
|
703
|
+
logger?.log("TELEPORT", "No pre-teleport needed", { targetScroll });
|
|
704
|
+
return targetScroll;
|
|
705
|
+
}
|
|
706
|
+
const direction = needsLeftPreTeleport ? "LEFT" : "RIGHT";
|
|
707
|
+
const offset = needsLeftPreTeleport ? originalSetWidth : -originalSetWidth;
|
|
708
|
+
const preTeleportStart = performance.now();
|
|
709
|
+
logger?.log("TELEPORT", `\u26A1 ${direction} PRE-TELEPORT START`, {
|
|
710
|
+
oldTarget: targetScroll.toFixed(1),
|
|
711
|
+
newTarget: (targetScroll + offset).toFixed(1)
|
|
712
|
+
});
|
|
713
|
+
const adjustedTarget = targetScroll + offset;
|
|
714
|
+
coordinator.transition({ type: "SET_PRE_TELEPORTING", value: true });
|
|
715
|
+
coordinator.transition({ type: "START_PRE_TELEPORT" });
|
|
716
|
+
logger?.log("TELEPORT", "isPreTeleporting = true (via coordinator)");
|
|
717
|
+
if (scrollEndListenerRef.current) {
|
|
718
|
+
logger?.log("TELEPORT", "Removing old scrollend listener before stop");
|
|
719
|
+
el.removeEventListener("scrollend", scrollEndListenerRef.current);
|
|
720
|
+
scrollEndListenerRef.current = null;
|
|
721
|
+
}
|
|
722
|
+
logger?.log("TELEPORT", "Stopping ongoing smooth scroll...");
|
|
723
|
+
el.scrollTo({ left: el.scrollLeft, behavior: "auto" });
|
|
724
|
+
coordinator.transition({ type: "SET_TELEPORTING", value: true });
|
|
725
|
+
const oldScrollLeft = el.scrollLeft;
|
|
726
|
+
const newScrollLeft = oldScrollLeft + offset;
|
|
727
|
+
el.scrollLeft = newScrollLeft;
|
|
728
|
+
logger?.log("TELEPORT", `Instant teleport: ${oldScrollLeft.toFixed(1)} \u2192 ${el.scrollLeft.toFixed(1)}`);
|
|
729
|
+
logger?.log("TELEPORT", "Applying visuals after teleport...");
|
|
730
|
+
requestAnimationFrame(() => applyVisuals(el, newScrollLeft));
|
|
731
|
+
coordinator.transition({ type: "SET_TELEPORTING", value: false });
|
|
732
|
+
coordinator.transition({ type: "SET_PENDING_TARGET", target: adjustedTarget });
|
|
733
|
+
setTimeout(() => {
|
|
734
|
+
coordinator.transition({ type: "SET_PRE_TELEPORTING", value: false });
|
|
735
|
+
logger?.log("TELEPORT", `isPreTeleporting = false (after ${preTeleportClearDelayMs}ms)`);
|
|
736
|
+
}, preTeleportClearDelayMs);
|
|
737
|
+
logger?.log("TELEPORT", `\u26A1 ${direction} PRE-TELEPORT END`, {
|
|
738
|
+
duration: `${(performance.now() - preTeleportStart).toFixed(1)}ms`
|
|
739
|
+
});
|
|
740
|
+
return adjustedTarget;
|
|
741
|
+
};
|
|
742
|
+
return {
|
|
743
|
+
/** Ref to track if current interaction is touch-based */
|
|
744
|
+
isTouchInteraction,
|
|
745
|
+
/** Proactive pre-teleport for arrow navigation */
|
|
746
|
+
preTeleport
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/hooks/useCarouselVisuals.ts
|
|
751
|
+
var import_react4 = require("react");
|
|
752
|
+
|
|
753
|
+
// src/config.ts
|
|
754
|
+
var VISUAL_CONFIG = {
|
|
755
|
+
MAX_DIST_MOBILE: 320,
|
|
756
|
+
MAX_DIST_TABLET: 400,
|
|
757
|
+
MAX_DIST_DESKTOP: 500,
|
|
758
|
+
BASE_SCALE_MOBILE: 0.8,
|
|
759
|
+
BASE_SCALE_TABLET: 0.82,
|
|
760
|
+
BASE_SCALE_DESKTOP: 0.85,
|
|
761
|
+
VIEW_BUFFER: 200,
|
|
762
|
+
CENTER_THRESHOLD: 10,
|
|
763
|
+
DISABLE_DYNAMIC_SHADOW: true
|
|
764
|
+
};
|
|
765
|
+
var TIMING_CONFIG = {
|
|
766
|
+
BOUNCE_DISTANCE_PX: 30,
|
|
767
|
+
BOUNCE_PHASE1_MS: 150,
|
|
768
|
+
BOUNCE_PHASE2_MS: 450,
|
|
769
|
+
// How long to wait before clearing the pre-teleport flag
|
|
770
|
+
PRE_TELEPORT_CLEAR_DELAY_MS: 100,
|
|
771
|
+
SCROLL_IDLE_FALLBACK_MS: 300,
|
|
772
|
+
SCROLL_DEBOUNCE_FALLBACK_MS: 50,
|
|
773
|
+
SNAP_RESTORE_DELAY_MS: 100,
|
|
774
|
+
RESIZE_DEBOUNCE_MS: 100,
|
|
775
|
+
SCROLL_PERSIST_DEBOUNCE_MS: 150,
|
|
776
|
+
// Milliseconds to wait for scroll to stop before considering it complete (fallback)
|
|
777
|
+
SCROLL_COMPLETION_DEBOUNCE_MS: 150,
|
|
778
|
+
// Tolerance for target arrival check (ratio of stride)
|
|
779
|
+
SCROLL_TARGET_TOLERANCE_RATIO: 0.5
|
|
780
|
+
};
|
|
781
|
+
var LAYOUT_CONFIG = {
|
|
782
|
+
GAP_MOBILE: 12,
|
|
783
|
+
GAP_DESKTOP: 16,
|
|
784
|
+
GAP_BREAKPOINT: 768,
|
|
785
|
+
MIN_BUFFER_COUNT: 50,
|
|
786
|
+
EDGE_TOLERANCE_START: 20,
|
|
787
|
+
EDGE_TOLERANCE_END: 5,
|
|
788
|
+
// Initial fallback values (should match --carousel-item-width-default in tailwind.css)
|
|
789
|
+
INITIAL_CARD_WIDTH: 180,
|
|
790
|
+
INITIAL_GAP: 16
|
|
791
|
+
};
|
|
792
|
+
var DEBUG_CONFIG = {
|
|
793
|
+
// 🛡️ SAFETY: This defaults to false in production.
|
|
794
|
+
// Even if set to true manually, the logger has a hard guard against console output in production.
|
|
795
|
+
ENABLED: false,
|
|
796
|
+
HISTORY_SIZE: 100,
|
|
797
|
+
// Enable specific channels to debug features
|
|
798
|
+
// Options: 'ALL', 'TELEPORT', 'VISUALS', 'LAYOUT', 'INIT', 'CACHE', 'COORDINATOR', 'NAV', 'INTERACT'
|
|
799
|
+
CHANNELS: {
|
|
800
|
+
ALL: false,
|
|
801
|
+
TELEPORT: false,
|
|
802
|
+
VISUALS: false,
|
|
803
|
+
LAYOUT: false,
|
|
804
|
+
INIT: false,
|
|
805
|
+
CACHE: false,
|
|
806
|
+
COORDINATOR: false,
|
|
807
|
+
NAV: false,
|
|
808
|
+
INTERACT: false,
|
|
809
|
+
PERF: false
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
var FEATURE_FLAGS = {
|
|
813
|
+
USE_RAF_FRAME_SEPARATION: true
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// src/hooks/useCarouselVisuals.ts
|
|
817
|
+
function useCarouselVisuals({
|
|
818
|
+
layout,
|
|
819
|
+
itemsCount,
|
|
820
|
+
bufferBeforeCount,
|
|
821
|
+
disableOpacityEffect,
|
|
822
|
+
disableScaleEffect,
|
|
823
|
+
logger
|
|
824
|
+
}) {
|
|
825
|
+
const childrenPositions = (0, import_react4.useRef)([]);
|
|
826
|
+
const isCacheDirty = (0, import_react4.useRef)(true);
|
|
827
|
+
const containerWidthRef = (0, import_react4.useRef)(0);
|
|
828
|
+
const isContainerWidthDirty = (0, import_react4.useRef)(true);
|
|
829
|
+
(0, import_react4.useEffect)(() => {
|
|
830
|
+
isCacheDirty.current = true;
|
|
831
|
+
isContainerWidthDirty.current = true;
|
|
832
|
+
logger?.log("VISUALS", "Marked cache dirty", { itemsCount, bufferBeforeCount });
|
|
833
|
+
}, [itemsCount, bufferBeforeCount, logger]);
|
|
834
|
+
const updateCache = (0, import_react4.useCallback)((el) => {
|
|
835
|
+
childrenPositions.current = Array.from(el.children).map((child) => {
|
|
836
|
+
const node = child;
|
|
837
|
+
return {
|
|
838
|
+
left: node.offsetLeft,
|
|
839
|
+
width: node.offsetWidth
|
|
840
|
+
};
|
|
841
|
+
});
|
|
842
|
+
logger?.log("VISUALS", "Updated positions cache", { count: childrenPositions.current.length });
|
|
843
|
+
}, [logger]);
|
|
844
|
+
const applyVisuals = (0, import_react4.useCallback)((el, overrideScrollLeft) => {
|
|
845
|
+
const currentScrollLeft = overrideScrollLeft ?? el.scrollLeft;
|
|
846
|
+
if (isContainerWidthDirty.current || containerWidthRef.current === 0) {
|
|
847
|
+
containerWidthRef.current = el.clientWidth;
|
|
848
|
+
isContainerWidthDirty.current = false;
|
|
849
|
+
}
|
|
850
|
+
const containerCenter = currentScrollLeft + containerWidthRef.current / 2;
|
|
851
|
+
if (childrenPositions.current.length === 0) {
|
|
852
|
+
logger?.log("VISUALS", "Skipping: positions cache empty");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const positions = childrenPositions.current;
|
|
856
|
+
if (disableOpacityEffect && disableScaleEffect) return;
|
|
857
|
+
const width = window.innerWidth;
|
|
858
|
+
const isMobile = width < 640;
|
|
859
|
+
const isTablet = width < 1024;
|
|
860
|
+
const maxDist = isMobile ? VISUAL_CONFIG.MAX_DIST_MOBILE : isTablet ? VISUAL_CONFIG.MAX_DIST_TABLET : VISUAL_CONFIG.MAX_DIST_DESKTOP;
|
|
861
|
+
const baseScale = isMobile ? VISUAL_CONFIG.BASE_SCALE_MOBILE : isTablet ? VISUAL_CONFIG.BASE_SCALE_TABLET : VISUAL_CONFIG.BASE_SCALE_DESKTOP;
|
|
862
|
+
const scaleRange = 1 - baseScale;
|
|
863
|
+
const viewStart = currentScrollLeft - VISUAL_CONFIG.VIEW_BUFFER;
|
|
864
|
+
const viewEnd = currentScrollLeft + containerWidthRef.current + VISUAL_CONFIG.VIEW_BUFFER;
|
|
865
|
+
const count = el.children.length;
|
|
866
|
+
const stride = layout.cardWidth + layout.gap;
|
|
867
|
+
const firstItemLeft = positions[0]?.left ?? 0;
|
|
868
|
+
let startIndex = 0;
|
|
869
|
+
let endIndex = count;
|
|
870
|
+
if (stride > 0) {
|
|
871
|
+
startIndex = Math.floor((viewStart - layout.cardWidth - firstItemLeft) / stride) - 4;
|
|
872
|
+
startIndex = Math.max(0, startIndex);
|
|
873
|
+
endIndex = Math.ceil((viewEnd - firstItemLeft) / stride) + 4;
|
|
874
|
+
endIndex = Math.min(count, endIndex);
|
|
875
|
+
}
|
|
876
|
+
const loopStart = performance.now();
|
|
877
|
+
let processedCount = 0;
|
|
878
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
879
|
+
const child = el.children[i];
|
|
880
|
+
const pos = positions[i];
|
|
881
|
+
if (!pos) continue;
|
|
882
|
+
if (pos.left + pos.width < viewStart || pos.left > viewEnd) {
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
processedCount++;
|
|
886
|
+
const childCenter = pos.left + pos.width / 2;
|
|
887
|
+
const dist = Math.abs(containerCenter - childCenter);
|
|
888
|
+
const normDist = Math.min(dist / maxDist, 1);
|
|
889
|
+
const factor = 1 - normDist;
|
|
890
|
+
const easeFactor = 1 - Math.pow(1 - factor, 3);
|
|
891
|
+
const opacity = 0.5 + 0.5 * easeFactor;
|
|
892
|
+
if (!disableOpacityEffect) {
|
|
893
|
+
child.style.opacity = `${opacity}`;
|
|
894
|
+
if (dist < VISUAL_CONFIG.CENTER_THRESHOLD) child.style.opacity = "1";
|
|
895
|
+
}
|
|
896
|
+
const zIndex = Math.round(easeFactor * 100);
|
|
897
|
+
child.style.zIndex = `${zIndex}`;
|
|
898
|
+
if (!disableScaleEffect && !VISUAL_CONFIG.DISABLE_DYNAMIC_SHADOW) {
|
|
899
|
+
const shadowOpacity = 0.12 * easeFactor;
|
|
900
|
+
child.style.boxShadow = `0 10px 20px -5px rgba(0, 0, 0, ${shadowOpacity})`;
|
|
901
|
+
}
|
|
902
|
+
if (!disableScaleEffect) {
|
|
903
|
+
const scale = baseScale + scaleRange * easeFactor;
|
|
904
|
+
child.style.transform = `scale(${scale})`;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const loopDuration = performance.now() - loopStart;
|
|
908
|
+
if (loopDuration > 1) {
|
|
909
|
+
logger?.log("PERF", `Visuals Loop: ${loopDuration.toFixed(2)}ms`, {
|
|
910
|
+
processed: processedCount,
|
|
911
|
+
total: count,
|
|
912
|
+
range: `${startIndex}-${endIndex}`
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}, [disableOpacityEffect, disableScaleEffect, logger, layout]);
|
|
916
|
+
return {
|
|
917
|
+
/** Position cache for all children */
|
|
918
|
+
childrenPositions,
|
|
919
|
+
/** Whether the cache needs to be rebuilt */
|
|
920
|
+
isCacheDirty,
|
|
921
|
+
/** Container width cache ref */
|
|
922
|
+
containerWidthRef,
|
|
923
|
+
/** Whether container width needs to be remeasured */
|
|
924
|
+
isContainerWidthDirty,
|
|
925
|
+
/** Update the position cache */
|
|
926
|
+
updateCache,
|
|
927
|
+
/** Apply visual effects to visible items */
|
|
928
|
+
applyVisuals
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// src/hooks/useCarouselPersistence.ts
|
|
933
|
+
var import_react5 = require("react");
|
|
934
|
+
function useCarouselPersistence({
|
|
935
|
+
persistKey,
|
|
936
|
+
debounceMs = 150
|
|
937
|
+
} = {}) {
|
|
938
|
+
const debounceTimerRef = (0, import_react5.useRef)(null);
|
|
939
|
+
const lastSavedRef = (0, import_react5.useRef)(null);
|
|
940
|
+
const storageKey = persistKey ? `carousel-scroll-${persistKey}` : null;
|
|
941
|
+
const getSavedPosition = (0, import_react5.useCallback)(() => {
|
|
942
|
+
if (!storageKey) return null;
|
|
943
|
+
try {
|
|
944
|
+
const stored = sessionStorage.getItem(storageKey);
|
|
945
|
+
if (stored === null) return null;
|
|
946
|
+
const parsed = parseInt(stored, 10);
|
|
947
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
948
|
+
} catch {
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
}, [storageKey]);
|
|
952
|
+
const savePositionImmediate = (0, import_react5.useCallback)((scrollLeft) => {
|
|
953
|
+
if (!storageKey) return;
|
|
954
|
+
if (!Number.isFinite(scrollLeft)) return;
|
|
955
|
+
if (lastSavedRef.current === scrollLeft) return;
|
|
956
|
+
lastSavedRef.current = scrollLeft;
|
|
957
|
+
try {
|
|
958
|
+
sessionStorage.setItem(storageKey, String(Math.round(scrollLeft)));
|
|
959
|
+
} catch {
|
|
960
|
+
}
|
|
961
|
+
}, [storageKey]);
|
|
962
|
+
const savePosition = (0, import_react5.useCallback)((scrollLeft) => {
|
|
963
|
+
if (!storageKey) return;
|
|
964
|
+
if (debounceTimerRef.current) {
|
|
965
|
+
clearTimeout(debounceTimerRef.current);
|
|
966
|
+
}
|
|
967
|
+
debounceTimerRef.current = setTimeout(() => {
|
|
968
|
+
savePositionImmediate(scrollLeft);
|
|
969
|
+
}, debounceMs);
|
|
970
|
+
}, [storageKey, debounceMs, savePositionImmediate]);
|
|
971
|
+
const clearPosition = (0, import_react5.useCallback)(() => {
|
|
972
|
+
if (!storageKey) return;
|
|
973
|
+
try {
|
|
974
|
+
sessionStorage.removeItem(storageKey);
|
|
975
|
+
lastSavedRef.current = null;
|
|
976
|
+
} catch {
|
|
977
|
+
}
|
|
978
|
+
}, [storageKey]);
|
|
979
|
+
(0, import_react5.useEffect)(() => {
|
|
980
|
+
return () => {
|
|
981
|
+
if (debounceTimerRef.current) {
|
|
982
|
+
clearTimeout(debounceTimerRef.current);
|
|
983
|
+
}
|
|
984
|
+
};
|
|
985
|
+
}, []);
|
|
986
|
+
return {
|
|
987
|
+
getSavedPosition,
|
|
988
|
+
savePosition,
|
|
989
|
+
savePositionImmediate,
|
|
990
|
+
clearPosition
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// src/hooks/useCarouselLayout.ts
|
|
995
|
+
var import_react6 = require("react");
|
|
996
|
+
function measureLayoutFromElement(container) {
|
|
997
|
+
const firstCard = container.firstElementChild;
|
|
998
|
+
if (!firstCard) {
|
|
999
|
+
return null;
|
|
1000
|
+
}
|
|
1001
|
+
const cardWidth = firstCard.getBoundingClientRect().width;
|
|
1002
|
+
const gap = window.innerWidth < LAYOUT_CONFIG.GAP_BREAKPOINT ? LAYOUT_CONFIG.GAP_MOBILE : LAYOUT_CONFIG.GAP_DESKTOP;
|
|
1003
|
+
let domStride = cardWidth + gap;
|
|
1004
|
+
const secondCard = container.children[1];
|
|
1005
|
+
if (secondCard) {
|
|
1006
|
+
domStride = secondCard.offsetLeft - firstCard.offsetLeft;
|
|
1007
|
+
}
|
|
1008
|
+
return { cardWidth, gap, domStride };
|
|
1009
|
+
}
|
|
1010
|
+
function useCarouselLayout({
|
|
1011
|
+
containerRef,
|
|
1012
|
+
onLayoutChange,
|
|
1013
|
+
resizeDebounceMs = TIMING_CONFIG.RESIZE_DEBOUNCE_MS,
|
|
1014
|
+
logger
|
|
1015
|
+
}) {
|
|
1016
|
+
const [layout, setLayout] = (0, import_react6.useState)({
|
|
1017
|
+
cardWidth: LAYOUT_CONFIG.INITIAL_CARD_WIDTH,
|
|
1018
|
+
gap: LAYOUT_CONFIG.INITIAL_GAP,
|
|
1019
|
+
domStride: LAYOUT_CONFIG.INITIAL_CARD_WIDTH + LAYOUT_CONFIG.INITIAL_GAP
|
|
1020
|
+
});
|
|
1021
|
+
const [resizeCount, setResizeCount] = (0, import_react6.useState)(0);
|
|
1022
|
+
const isLayoutDirty = (0, import_react6.useRef)(true);
|
|
1023
|
+
const onLayoutChangeRef = (0, import_react6.useRef)(onLayoutChange);
|
|
1024
|
+
onLayoutChangeRef.current = onLayoutChange;
|
|
1025
|
+
const loggerRef = (0, import_react6.useRef)(logger);
|
|
1026
|
+
loggerRef.current = logger;
|
|
1027
|
+
const stride = layout.cardWidth + layout.gap;
|
|
1028
|
+
const isMobile = typeof window !== "undefined" && window.innerWidth < 640;
|
|
1029
|
+
const isTablet = typeof window !== "undefined" && window.innerWidth >= 640 && window.innerWidth < 1024;
|
|
1030
|
+
const measureLayout = (0, import_react6.useCallback)(() => {
|
|
1031
|
+
const el = containerRef.current;
|
|
1032
|
+
if (!el) {
|
|
1033
|
+
logger?.log("LAYOUT", "No container element");
|
|
1034
|
+
return layout;
|
|
1035
|
+
}
|
|
1036
|
+
const measured = measureLayoutFromElement(el);
|
|
1037
|
+
if (measured === null) {
|
|
1038
|
+
logger?.log("LAYOUT", "Children not rendered, keeping current layout");
|
|
1039
|
+
return layout;
|
|
1040
|
+
}
|
|
1041
|
+
logger?.log("LAYOUT", "Layout measured", measured);
|
|
1042
|
+
setLayout((prev) => {
|
|
1043
|
+
if (prev.cardWidth === measured.cardWidth && prev.gap === measured.gap && prev.domStride === measured.domStride) {
|
|
1044
|
+
logger?.log("LAYOUT", "Layout unchanged, skipping update");
|
|
1045
|
+
return prev;
|
|
1046
|
+
}
|
|
1047
|
+
logger?.log("LAYOUT", "Layout changed, updating state", {
|
|
1048
|
+
old: prev,
|
|
1049
|
+
new: measured
|
|
1050
|
+
});
|
|
1051
|
+
if (onLayoutChangeRef.current) {
|
|
1052
|
+
onLayoutChangeRef.current(measured);
|
|
1053
|
+
}
|
|
1054
|
+
return measured;
|
|
1055
|
+
});
|
|
1056
|
+
isLayoutDirty.current = false;
|
|
1057
|
+
return measured;
|
|
1058
|
+
}, [containerRef, layout, logger]);
|
|
1059
|
+
const invalidateLayout = (0, import_react6.useCallback)(() => {
|
|
1060
|
+
isLayoutDirty.current = true;
|
|
1061
|
+
logger?.log("LAYOUT", "Layout marked as dirty");
|
|
1062
|
+
}, [logger]);
|
|
1063
|
+
const measureLayoutRef = (0, import_react6.useRef)(measureLayout);
|
|
1064
|
+
measureLayoutRef.current = measureLayout;
|
|
1065
|
+
(0, import_react6.useEffect)(() => {
|
|
1066
|
+
const el = containerRef.current;
|
|
1067
|
+
if (!el) return;
|
|
1068
|
+
let timeoutId;
|
|
1069
|
+
let isFirstObservation = true;
|
|
1070
|
+
const ro = new ResizeObserver(() => {
|
|
1071
|
+
if (isFirstObservation) {
|
|
1072
|
+
isFirstObservation = false;
|
|
1073
|
+
loggerRef.current?.log("LAYOUT", "First observation - measuring immediately");
|
|
1074
|
+
measureLayoutRef.current();
|
|
1075
|
+
setResizeCount((c) => c + 1);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
clearTimeout(timeoutId);
|
|
1079
|
+
timeoutId = setTimeout(() => {
|
|
1080
|
+
loggerRef.current?.log("LAYOUT", "Container resized, remeasuring layout");
|
|
1081
|
+
measureLayoutRef.current();
|
|
1082
|
+
setResizeCount((c) => c + 1);
|
|
1083
|
+
}, resizeDebounceMs);
|
|
1084
|
+
});
|
|
1085
|
+
ro.observe(el);
|
|
1086
|
+
loggerRef.current?.log("LAYOUT", "ResizeObserver attached");
|
|
1087
|
+
return () => {
|
|
1088
|
+
ro.disconnect();
|
|
1089
|
+
clearTimeout(timeoutId);
|
|
1090
|
+
loggerRef.current?.log("LAYOUT", "ResizeObserver disconnected");
|
|
1091
|
+
};
|
|
1092
|
+
}, [containerRef, resizeDebounceMs]);
|
|
1093
|
+
return {
|
|
1094
|
+
layout,
|
|
1095
|
+
stride,
|
|
1096
|
+
isMobile,
|
|
1097
|
+
isTablet,
|
|
1098
|
+
measureLayout,
|
|
1099
|
+
invalidateLayout,
|
|
1100
|
+
resizeCount
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// src/hooks/useCarouselNavigation.ts
|
|
1105
|
+
var import_react8 = require("react");
|
|
1106
|
+
|
|
1107
|
+
// src/hooks/useScrollCompletion.ts
|
|
1108
|
+
var import_react7 = require("react");
|
|
1109
|
+
function useScrollCompletion({
|
|
1110
|
+
ref,
|
|
1111
|
+
onComplete,
|
|
1112
|
+
listenerRef,
|
|
1113
|
+
timeoutRef
|
|
1114
|
+
}) {
|
|
1115
|
+
const internalListenerRef = (0, import_react7.useRef)(null);
|
|
1116
|
+
const activeListenerRef = listenerRef || internalListenerRef;
|
|
1117
|
+
const internalTimeoutRef = (0, import_react7.useRef)(null);
|
|
1118
|
+
const activeTimeoutRef = timeoutRef || internalTimeoutRef;
|
|
1119
|
+
const supportsScrollEnd = typeof window !== "undefined" && "onscrollend" in window;
|
|
1120
|
+
const waitForScrollCompletion = (0, import_react7.useCallback)((timeoutDuration = TIMING_CONFIG.SCROLL_IDLE_FALLBACK_MS) => {
|
|
1121
|
+
const el = ref.current;
|
|
1122
|
+
if (!el) return;
|
|
1123
|
+
if (activeListenerRef.current) {
|
|
1124
|
+
el.removeEventListener("scrollend", activeListenerRef.current);
|
|
1125
|
+
el.removeEventListener("scroll", activeListenerRef.current);
|
|
1126
|
+
activeListenerRef.current = null;
|
|
1127
|
+
}
|
|
1128
|
+
if (activeTimeoutRef.current) {
|
|
1129
|
+
clearTimeout(activeTimeoutRef.current);
|
|
1130
|
+
activeTimeoutRef.current = null;
|
|
1131
|
+
}
|
|
1132
|
+
if (supportsScrollEnd) {
|
|
1133
|
+
const listener = () => {
|
|
1134
|
+
activeListenerRef.current = null;
|
|
1135
|
+
onComplete("scrollend");
|
|
1136
|
+
};
|
|
1137
|
+
activeListenerRef.current = listener;
|
|
1138
|
+
el.addEventListener("scrollend", listener, { once: true });
|
|
1139
|
+
} else {
|
|
1140
|
+
let debounceTimer;
|
|
1141
|
+
const debounceListener = () => {
|
|
1142
|
+
clearTimeout(debounceTimer);
|
|
1143
|
+
debounceTimer = setTimeout(() => {
|
|
1144
|
+
if (activeListenerRef.current) {
|
|
1145
|
+
el.removeEventListener("scroll", activeListenerRef.current);
|
|
1146
|
+
activeListenerRef.current = null;
|
|
1147
|
+
}
|
|
1148
|
+
if (activeTimeoutRef.current) {
|
|
1149
|
+
clearTimeout(activeTimeoutRef.current);
|
|
1150
|
+
activeTimeoutRef.current = null;
|
|
1151
|
+
}
|
|
1152
|
+
onComplete("debounce");
|
|
1153
|
+
}, TIMING_CONFIG.SCROLL_DEBOUNCE_FALLBACK_MS);
|
|
1154
|
+
};
|
|
1155
|
+
activeListenerRef.current = debounceListener;
|
|
1156
|
+
el.addEventListener("scroll", debounceListener, { passive: true });
|
|
1157
|
+
activeTimeoutRef.current = setTimeout(() => {
|
|
1158
|
+
if (activeListenerRef.current) {
|
|
1159
|
+
el.removeEventListener("scroll", activeListenerRef.current);
|
|
1160
|
+
activeListenerRef.current = null;
|
|
1161
|
+
}
|
|
1162
|
+
onComplete("safety-timeout");
|
|
1163
|
+
}, timeoutDuration);
|
|
1164
|
+
}
|
|
1165
|
+
}, [ref, onComplete, supportsScrollEnd]);
|
|
1166
|
+
(0, import_react7.useEffect)(() => {
|
|
1167
|
+
return () => {
|
|
1168
|
+
const el = ref.current;
|
|
1169
|
+
if (el && activeListenerRef.current) {
|
|
1170
|
+
el.removeEventListener("scrollend", activeListenerRef.current);
|
|
1171
|
+
el.removeEventListener("scroll", activeListenerRef.current);
|
|
1172
|
+
}
|
|
1173
|
+
if (activeTimeoutRef.current) {
|
|
1174
|
+
clearTimeout(activeTimeoutRef.current);
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
}, [ref]);
|
|
1178
|
+
return {
|
|
1179
|
+
waitForScrollCompletion
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// src/hooks/useCarouselNavigation.ts
|
|
1184
|
+
function useCarouselNavigation({
|
|
1185
|
+
containerRef,
|
|
1186
|
+
infinite,
|
|
1187
|
+
layout,
|
|
1188
|
+
cancelMomentum,
|
|
1189
|
+
preTeleport,
|
|
1190
|
+
onNavigate,
|
|
1191
|
+
coordinator,
|
|
1192
|
+
logger
|
|
1193
|
+
}) {
|
|
1194
|
+
const scrollEndListenerRef = (0, import_react8.useRef)(null);
|
|
1195
|
+
const scrollIdleTimeoutRef = (0, import_react8.useRef)(null);
|
|
1196
|
+
const snapTimeoutRef = (0, import_react8.useRef)(null);
|
|
1197
|
+
const debugClickCountRef = (0, import_react8.useRef)(0);
|
|
1198
|
+
const onAnimationComplete = (0, import_react8.useCallback)((source) => {
|
|
1199
|
+
const el = containerRef.current;
|
|
1200
|
+
if (!el) return;
|
|
1201
|
+
const ctx = coordinator.getContext();
|
|
1202
|
+
if (ctx.pendingTarget === null && source !== "safety-timeout") {
|
|
1203
|
+
logger?.log("NAV", `#${debugClickCountRef.current} Arrow nav ${source} - Skipping cleanup (Interrupted)`);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
if (ctx.isPreTeleporting) return;
|
|
1207
|
+
const currentPos = el.scrollLeft;
|
|
1208
|
+
const targetPos = ctx.pendingTarget;
|
|
1209
|
+
const stride = layout.cardWidth + layout.gap;
|
|
1210
|
+
if (targetPos !== null) {
|
|
1211
|
+
const distanceToTarget = Math.abs(currentPos - targetPos);
|
|
1212
|
+
if (distanceToTarget > stride * TIMING_CONFIG.SCROLL_TARGET_TOLERANCE_RATIO) {
|
|
1213
|
+
logger?.log("NAV", `#${debugClickCountRef.current} Ignoring premature ${source} - not at target yet`, {
|
|
1214
|
+
currentPos: currentPos.toFixed(1),
|
|
1215
|
+
targetPos: targetPos.toFixed(1),
|
|
1216
|
+
distanceToTarget: distanceToTarget.toFixed(1)
|
|
1217
|
+
});
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
logger?.log("NAV", `#${debugClickCountRef.current} Arrow nav complete via ${source}`);
|
|
1222
|
+
scrollEndListenerRef.current = null;
|
|
1223
|
+
coordinator.transition({ type: "SCROLL_COMPLETE" });
|
|
1224
|
+
if (infinite && el) el.style.scrollSnapType = "";
|
|
1225
|
+
}, [containerRef, coordinator, infinite, layout.cardWidth, layout.gap, logger]);
|
|
1226
|
+
const { waitForScrollCompletion } = useScrollCompletion({
|
|
1227
|
+
ref: containerRef,
|
|
1228
|
+
listenerRef: scrollEndListenerRef,
|
|
1229
|
+
timeoutRef: scrollIdleTimeoutRef,
|
|
1230
|
+
onComplete: onAnimationComplete
|
|
1231
|
+
});
|
|
1232
|
+
const handleScrollNav = (0, import_react8.useCallback)((direction) => {
|
|
1233
|
+
const clickStartTime = performance.now();
|
|
1234
|
+
debugClickCountRef.current++;
|
|
1235
|
+
const thisClickId = debugClickCountRef.current;
|
|
1236
|
+
logger?.log("NAV", `\u2501\u2501\u2501 Arrow Click #${thisClickId} START \u2501\u2501\u2501`, {
|
|
1237
|
+
direction: direction === 1 ? "RIGHT \u2192" : "\u2190 LEFT",
|
|
1238
|
+
timestamp: clickStartTime.toFixed(1)
|
|
1239
|
+
});
|
|
1240
|
+
const el = containerRef.current;
|
|
1241
|
+
if (!el) {
|
|
1242
|
+
logger?.log("NAV", `#${thisClickId} ABORT: No element ref`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const ctx = coordinator.getContext();
|
|
1246
|
+
if (coordinator.getPhase() === "BOUNCING") {
|
|
1247
|
+
logger?.log("NAV", `#${thisClickId} ABORT: Currently bouncing`);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
const perfStart = performance.now();
|
|
1251
|
+
const stride = layout.cardWidth + layout.gap;
|
|
1252
|
+
const currentScroll = el.scrollLeft;
|
|
1253
|
+
const maxScroll = el.scrollWidth - el.clientWidth;
|
|
1254
|
+
const readTime = performance.now() - perfStart;
|
|
1255
|
+
logger?.log("PERF", `Layout Read: ${readTime.toFixed(2)}ms`, {
|
|
1256
|
+
safe: readTime < 1,
|
|
1257
|
+
threshold: "1.0ms"
|
|
1258
|
+
});
|
|
1259
|
+
logger?.log("NAV", `#${thisClickId} Cancelling momentum...`);
|
|
1260
|
+
cancelMomentum();
|
|
1261
|
+
logger?.log("NAV", `#${thisClickId} Current state`, {
|
|
1262
|
+
currentScroll: currentScroll.toFixed(1),
|
|
1263
|
+
stride,
|
|
1264
|
+
maxScroll: maxScroll.toFixed(1),
|
|
1265
|
+
infinite
|
|
1266
|
+
});
|
|
1267
|
+
logger?.log("NAV", `#${thisClickId} Current state`, {
|
|
1268
|
+
currentScroll: currentScroll.toFixed(1),
|
|
1269
|
+
stride,
|
|
1270
|
+
maxScroll: maxScroll.toFixed(1),
|
|
1271
|
+
infinite
|
|
1272
|
+
});
|
|
1273
|
+
if (!infinite) {
|
|
1274
|
+
const isAtStart = currentScroll <= LAYOUT_CONFIG.EDGE_TOLERANCE_START;
|
|
1275
|
+
const isAtEnd = currentScroll >= maxScroll - LAYOUT_CONFIG.EDGE_TOLERANCE_END;
|
|
1276
|
+
if (direction === -1 && isAtStart || direction === 1 && isAtEnd) {
|
|
1277
|
+
logger?.log("NAV", `#${thisClickId} Edge reached, bouncing`, { isAtStart, isAtEnd });
|
|
1278
|
+
const bounceAmount = direction * TIMING_CONFIG.BOUNCE_DISTANCE_PX;
|
|
1279
|
+
const bounceTimeoutId = setTimeout(() => {
|
|
1280
|
+
el.style.transition = "";
|
|
1281
|
+
el.style.transform = "";
|
|
1282
|
+
coordinator.transition({ type: "END_BOUNCE" });
|
|
1283
|
+
logger?.log("NAV", `#${thisClickId} Bounce complete`);
|
|
1284
|
+
}, TIMING_CONFIG.BOUNCE_PHASE2_MS);
|
|
1285
|
+
coordinator.transition({ type: "START_BOUNCE", timeoutId: bounceTimeoutId });
|
|
1286
|
+
el.style.transition = "transform 0.15s ease-out";
|
|
1287
|
+
el.style.transform = `translateX(${-bounceAmount}px)`;
|
|
1288
|
+
setTimeout(() => {
|
|
1289
|
+
el.style.transition = "transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)";
|
|
1290
|
+
el.style.transform = "translateX(0)";
|
|
1291
|
+
}, TIMING_CONFIG.BOUNCE_PHASE1_MS);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
if (infinite) {
|
|
1296
|
+
el.style.scrollSnapType = "none";
|
|
1297
|
+
if (snapTimeoutRef.current) {
|
|
1298
|
+
logger?.log("NAV", `#${thisClickId} Clearing previous snap timeout`);
|
|
1299
|
+
clearTimeout(snapTimeoutRef.current);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
let targetScroll;
|
|
1303
|
+
let activeStride = stride;
|
|
1304
|
+
let paddingOffset = 0;
|
|
1305
|
+
if (infinite && el.children.length > 0) {
|
|
1306
|
+
const firstChild = el.children[0];
|
|
1307
|
+
paddingOffset = firstChild.offsetLeft;
|
|
1308
|
+
if (el.children.length > 1) {
|
|
1309
|
+
const secondChild = el.children[1];
|
|
1310
|
+
const domStride = secondChild.offsetLeft - firstChild.offsetLeft;
|
|
1311
|
+
if (domStride > 0 && Math.abs(domStride - stride) > 1) {
|
|
1312
|
+
activeStride = domStride;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
const pendingTarget = ctx.pendingTarget;
|
|
1317
|
+
if (pendingTarget !== null) {
|
|
1318
|
+
const previousTarget = pendingTarget;
|
|
1319
|
+
if (scrollEndListenerRef.current) {
|
|
1320
|
+
el.removeEventListener("scrollend", scrollEndListenerRef.current);
|
|
1321
|
+
el.removeEventListener("scroll", scrollEndListenerRef.current);
|
|
1322
|
+
scrollEndListenerRef.current = null;
|
|
1323
|
+
logger?.log("NAV", `#${thisClickId} Removed old scrollend listener before catch-up`);
|
|
1324
|
+
}
|
|
1325
|
+
el.scrollTo({ left: previousTarget, behavior: "auto" });
|
|
1326
|
+
targetScroll = previousTarget + direction * activeStride;
|
|
1327
|
+
logger?.log("NAV", `#${thisClickId} Mid-animation: Caught up to ${previousTarget}, now targeting`, {
|
|
1328
|
+
caughtUpTo: previousTarget,
|
|
1329
|
+
direction,
|
|
1330
|
+
activeStride,
|
|
1331
|
+
targetScroll: targetScroll.toFixed(1)
|
|
1332
|
+
});
|
|
1333
|
+
} else {
|
|
1334
|
+
const currentIndex = Math.round(currentScroll / activeStride);
|
|
1335
|
+
const nextIndex = currentIndex + direction;
|
|
1336
|
+
let domTargetFound = false;
|
|
1337
|
+
if (infinite && el.children[nextIndex]) {
|
|
1338
|
+
const targetNode = el.children[nextIndex];
|
|
1339
|
+
targetScroll = targetNode.offsetLeft - paddingOffset;
|
|
1340
|
+
domTargetFound = true;
|
|
1341
|
+
} else {
|
|
1342
|
+
targetScroll = paddingOffset + nextIndex * activeStride;
|
|
1343
|
+
}
|
|
1344
|
+
logger?.log("NAV", `#${thisClickId} Idle target calculation`, {
|
|
1345
|
+
currentIndex,
|
|
1346
|
+
nextIndex,
|
|
1347
|
+
direction,
|
|
1348
|
+
paddingOffset,
|
|
1349
|
+
activeStride,
|
|
1350
|
+
method: domTargetFound ? "DOM_EXACT" : "MATH_APPROX",
|
|
1351
|
+
targetScroll: targetScroll.toFixed(1)
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
if (infinite && preTeleport) {
|
|
1355
|
+
targetScroll = preTeleport(targetScroll);
|
|
1356
|
+
}
|
|
1357
|
+
coordinator.transition({ type: "ARROW_CLICK", direction, targetScroll });
|
|
1358
|
+
logger?.log("NAV", `#${thisClickId} pendingScrollTarget = ${targetScroll.toFixed(1)}`);
|
|
1359
|
+
if (onNavigate) {
|
|
1360
|
+
onNavigate(targetScroll);
|
|
1361
|
+
}
|
|
1362
|
+
logger?.log("NAV", `#${thisClickId} \u{1F680} Starting smooth scroll to ${targetScroll.toFixed(1)}`);
|
|
1363
|
+
if (FEATURE_FLAGS.USE_RAF_FRAME_SEPARATION) {
|
|
1364
|
+
requestAnimationFrame(() => {
|
|
1365
|
+
el.scrollTo({
|
|
1366
|
+
left: targetScroll,
|
|
1367
|
+
behavior: "smooth"
|
|
1368
|
+
});
|
|
1369
|
+
});
|
|
1370
|
+
} else {
|
|
1371
|
+
el.scrollTo({
|
|
1372
|
+
left: targetScroll,
|
|
1373
|
+
behavior: "smooth"
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
waitForScrollCompletion();
|
|
1377
|
+
const clickDuration = performance.now() - clickStartTime;
|
|
1378
|
+
logger?.log("NAV", `\u2501\u2501\u2501 Arrow Click #${thisClickId} END \u2501\u2501\u2501`, {
|
|
1379
|
+
totalDuration: `${clickDuration.toFixed(1)}ms`,
|
|
1380
|
+
finalTarget: targetScroll.toFixed(1)
|
|
1381
|
+
});
|
|
1382
|
+
}, [containerRef, infinite, layout, cancelMomentum, preTeleport, onNavigate, coordinator, waitForScrollCompletion, logger]);
|
|
1383
|
+
const scrollLeft = (0, import_react8.useCallback)(() => handleScrollNav(-1), [handleScrollNav]);
|
|
1384
|
+
const scrollRight = (0, import_react8.useCallback)(() => handleScrollNav(1), [handleScrollNav]);
|
|
1385
|
+
return {
|
|
1386
|
+
scrollLeft,
|
|
1387
|
+
scrollRight,
|
|
1388
|
+
handleScrollNav
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
// src/hooks/useCarouselCoordinator.ts
|
|
1393
|
+
var import_react9 = require("react");
|
|
1394
|
+
var createInitialContext = () => ({
|
|
1395
|
+
phase: "UNINITIALIZED",
|
|
1396
|
+
pendingTarget: null,
|
|
1397
|
+
scrollDirection: null,
|
|
1398
|
+
teleportOffset: null,
|
|
1399
|
+
isTeleporting: false,
|
|
1400
|
+
isPreTeleporting: false,
|
|
1401
|
+
lastActiveItemKey: null,
|
|
1402
|
+
snapTimeoutId: null,
|
|
1403
|
+
scrollIdleTimeoutId: null,
|
|
1404
|
+
bounceTimeoutId: null,
|
|
1405
|
+
hasScrollEndListener: false
|
|
1406
|
+
});
|
|
1407
|
+
function reduce(context, action) {
|
|
1408
|
+
switch (action.type) {
|
|
1409
|
+
case "INITIALIZE": {
|
|
1410
|
+
return {
|
|
1411
|
+
...context,
|
|
1412
|
+
phase: "IDLE"
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
case "ARROW_CLICK": {
|
|
1416
|
+
if (context.phase !== "IDLE" && context.phase !== "SCROLLING") {
|
|
1417
|
+
return context;
|
|
1418
|
+
}
|
|
1419
|
+
return {
|
|
1420
|
+
...context,
|
|
1421
|
+
phase: "SCROLLING",
|
|
1422
|
+
pendingTarget: action.targetScroll,
|
|
1423
|
+
scrollDirection: action.direction
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
case "ITEM_CLICK": {
|
|
1427
|
+
if (context.phase !== "IDLE" && context.phase !== "SCROLLING") {
|
|
1428
|
+
return context;
|
|
1429
|
+
}
|
|
1430
|
+
return {
|
|
1431
|
+
...context,
|
|
1432
|
+
phase: "SCROLLING",
|
|
1433
|
+
pendingTarget: action.targetScroll,
|
|
1434
|
+
scrollDirection: null
|
|
1435
|
+
// Direction not applicable for direct clicks
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
case "SCROLL_COMPLETE": {
|
|
1439
|
+
if (context.phase !== "SCROLLING") {
|
|
1440
|
+
return context;
|
|
1441
|
+
}
|
|
1442
|
+
return {
|
|
1443
|
+
...context,
|
|
1444
|
+
phase: "IDLE",
|
|
1445
|
+
pendingTarget: null,
|
|
1446
|
+
scrollDirection: null,
|
|
1447
|
+
hasScrollEndListener: false
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
case "USER_INTERRUPT": {
|
|
1451
|
+
if (context.phase === "SCROLLING") {
|
|
1452
|
+
return {
|
|
1453
|
+
...context,
|
|
1454
|
+
phase: "IDLE",
|
|
1455
|
+
pendingTarget: null,
|
|
1456
|
+
scrollDirection: null
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
return context;
|
|
1460
|
+
}
|
|
1461
|
+
case "START_BOUNCE": {
|
|
1462
|
+
if (context.phase !== "IDLE") {
|
|
1463
|
+
return context;
|
|
1464
|
+
}
|
|
1465
|
+
return {
|
|
1466
|
+
...context,
|
|
1467
|
+
phase: "BOUNCING",
|
|
1468
|
+
bounceTimeoutId: action.timeoutId
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
case "END_BOUNCE": {
|
|
1472
|
+
if (context.phase !== "BOUNCING") {
|
|
1473
|
+
return context;
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
...context,
|
|
1477
|
+
phase: "IDLE",
|
|
1478
|
+
bounceTimeoutId: null
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
case "START_PRE_TELEPORT": {
|
|
1482
|
+
return {
|
|
1483
|
+
...context,
|
|
1484
|
+
phase: "PRE_TELEPORTING"
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
case "EXECUTE_TELEPORT": {
|
|
1488
|
+
if (context.phase !== "PRE_TELEPORTING") {
|
|
1489
|
+
return context;
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
...context,
|
|
1493
|
+
phase: "TELEPORTING",
|
|
1494
|
+
teleportOffset: action.offset
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
case "END_TELEPORT": {
|
|
1498
|
+
if (context.phase !== "TELEPORTING") {
|
|
1499
|
+
return context;
|
|
1500
|
+
}
|
|
1501
|
+
const nextPhase = context.pendingTarget !== null ? "SCROLLING" : "IDLE";
|
|
1502
|
+
return {
|
|
1503
|
+
...context,
|
|
1504
|
+
phase: nextPhase,
|
|
1505
|
+
teleportOffset: null
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
case "START_DRAG": {
|
|
1509
|
+
return {
|
|
1510
|
+
...context,
|
|
1511
|
+
phase: "DRAGGING",
|
|
1512
|
+
pendingTarget: null,
|
|
1513
|
+
// Cancel any pending scroll
|
|
1514
|
+
scrollDirection: null
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
case "END_DRAG": {
|
|
1518
|
+
if (context.phase !== "DRAGGING") {
|
|
1519
|
+
return context;
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
...context,
|
|
1523
|
+
phase: "IDLE"
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
// Timer management actions (don't change phase)
|
|
1527
|
+
case "SET_SNAP_TIMEOUT": {
|
|
1528
|
+
return { ...context, snapTimeoutId: action.timeoutId };
|
|
1529
|
+
}
|
|
1530
|
+
case "CLEAR_SNAP_TIMEOUT": {
|
|
1531
|
+
return { ...context, snapTimeoutId: null };
|
|
1532
|
+
}
|
|
1533
|
+
case "SET_SCROLL_IDLE_TIMEOUT": {
|
|
1534
|
+
return { ...context, scrollIdleTimeoutId: action.timeoutId };
|
|
1535
|
+
}
|
|
1536
|
+
case "CLEAR_SCROLL_IDLE_TIMEOUT": {
|
|
1537
|
+
return { ...context, scrollIdleTimeoutId: null };
|
|
1538
|
+
}
|
|
1539
|
+
case "SET_SCROLL_END_LISTENER": {
|
|
1540
|
+
return { ...context, hasScrollEndListener: action.hasListener };
|
|
1541
|
+
}
|
|
1542
|
+
case "SET_ACTIVE_ITEM_KEY": {
|
|
1543
|
+
return { ...context, lastActiveItemKey: action.key };
|
|
1544
|
+
}
|
|
1545
|
+
case "SET_TELEPORTING": {
|
|
1546
|
+
return { ...context, isTeleporting: action.value };
|
|
1547
|
+
}
|
|
1548
|
+
case "SET_PRE_TELEPORTING": {
|
|
1549
|
+
if (action.value) {
|
|
1550
|
+
return { ...context, isPreTeleporting: true };
|
|
1551
|
+
} else {
|
|
1552
|
+
const nextPhase = context.pendingTarget !== null ? "SCROLLING" : "IDLE";
|
|
1553
|
+
return {
|
|
1554
|
+
...context,
|
|
1555
|
+
isPreTeleporting: false,
|
|
1556
|
+
phase: nextPhase
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
case "SET_PENDING_TARGET": {
|
|
1561
|
+
return { ...context, pendingTarget: action.target };
|
|
1562
|
+
}
|
|
1563
|
+
default: {
|
|
1564
|
+
const _exhaustive = action;
|
|
1565
|
+
return context;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
function useCarouselCoordinator(options) {
|
|
1570
|
+
const contextRef = (0, import_react9.useRef)(createInitialContext());
|
|
1571
|
+
const loggerRef = (0, import_react9.useRef)(options?.logger);
|
|
1572
|
+
loggerRef.current = options?.logger;
|
|
1573
|
+
const transition = (0, import_react9.useCallback)((action) => {
|
|
1574
|
+
const prevContext = contextRef.current;
|
|
1575
|
+
const prevPhase = prevContext.phase;
|
|
1576
|
+
const nextContext = reduce(prevContext, action);
|
|
1577
|
+
contextRef.current = nextContext;
|
|
1578
|
+
if (loggerRef.current) {
|
|
1579
|
+
const arrow = prevPhase === nextContext.phase ? "\u2022" : "\u2192";
|
|
1580
|
+
loggerRef.current.log("COORDINATOR", `${prevPhase} ${arrow} ${action.type} ${arrow} ${nextContext.phase}`, {
|
|
1581
|
+
action: action.type,
|
|
1582
|
+
prevPhase,
|
|
1583
|
+
nextPhase: nextContext.phase,
|
|
1584
|
+
..."targetScroll" in action ? { target: action.targetScroll } : {},
|
|
1585
|
+
..."direction" in action ? { direction: action.direction } : {}
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
return nextContext;
|
|
1589
|
+
}, []);
|
|
1590
|
+
const getPhase = (0, import_react9.useCallback)(() => {
|
|
1591
|
+
return contextRef.current.phase;
|
|
1592
|
+
}, []);
|
|
1593
|
+
const getContext = (0, import_react9.useCallback)(() => {
|
|
1594
|
+
return contextRef.current;
|
|
1595
|
+
}, []);
|
|
1596
|
+
const isBusy = (0, import_react9.useCallback)(() => {
|
|
1597
|
+
const phase = contextRef.current.phase;
|
|
1598
|
+
return phase !== "IDLE" && phase !== "UNINITIALIZED";
|
|
1599
|
+
}, []);
|
|
1600
|
+
const isBlocking = (0, import_react9.useCallback)(() => {
|
|
1601
|
+
const phase = contextRef.current.phase;
|
|
1602
|
+
return phase === "BOUNCING" || phase === "TELEPORTING";
|
|
1603
|
+
}, []);
|
|
1604
|
+
return {
|
|
1605
|
+
transition,
|
|
1606
|
+
getPhase,
|
|
1607
|
+
getContext,
|
|
1608
|
+
contextRef,
|
|
1609
|
+
isBusy,
|
|
1610
|
+
isBlocking
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// src/CarouselArrow.tsx
|
|
1615
|
+
var import_clsx = __toESM(require("clsx"), 1);
|
|
1616
|
+
var import_react_i18next = require("react-i18next");
|
|
1617
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
1618
|
+
function CarouselArrow({
|
|
1619
|
+
direction,
|
|
1620
|
+
onClick,
|
|
1621
|
+
disabled = false,
|
|
1622
|
+
className
|
|
1623
|
+
}) {
|
|
1624
|
+
const { t } = (0, import_react_i18next.useTranslation)();
|
|
1625
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1626
|
+
"button",
|
|
1627
|
+
{
|
|
1628
|
+
onClick,
|
|
1629
|
+
disabled,
|
|
1630
|
+
className: (0, import_clsx.default)(
|
|
1631
|
+
"carousel-button",
|
|
1632
|
+
// Base class from global CSS
|
|
1633
|
+
direction === "left" ? "prev" : "next",
|
|
1634
|
+
// Positioning classes
|
|
1635
|
+
"disabled:opacity-0 disabled:cursor-not-allowed disabled:pointer-events-none",
|
|
1636
|
+
// State modifiers
|
|
1637
|
+
className
|
|
1638
|
+
),
|
|
1639
|
+
"aria-label": direction === "left" ? t("carousel.previous") : t("carousel.next"),
|
|
1640
|
+
onPointerDown: (e) => {
|
|
1641
|
+
e.stopPropagation();
|
|
1642
|
+
e.preventDefault();
|
|
1643
|
+
},
|
|
1644
|
+
children: direction === "left" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1645
|
+
"svg",
|
|
1646
|
+
{
|
|
1647
|
+
width: "24",
|
|
1648
|
+
height: "24",
|
|
1649
|
+
viewBox: "0 0 24 24",
|
|
1650
|
+
fill: "none",
|
|
1651
|
+
stroke: "currentColor",
|
|
1652
|
+
strokeWidth: "2",
|
|
1653
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M15 18l-6-6 6-6" })
|
|
1654
|
+
}
|
|
1655
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1656
|
+
"svg",
|
|
1657
|
+
{
|
|
1658
|
+
width: "24",
|
|
1659
|
+
height: "24",
|
|
1660
|
+
viewBox: "0 0 24 24",
|
|
1661
|
+
fill: "none",
|
|
1662
|
+
stroke: "currentColor",
|
|
1663
|
+
strokeWidth: "2",
|
|
1664
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M9 18l6-6-6-6" })
|
|
1665
|
+
}
|
|
1666
|
+
)
|
|
1667
|
+
}
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/logger.ts
|
|
1672
|
+
var CHANNEL_COLORS = {
|
|
1673
|
+
ALL: "#ffffff",
|
|
1674
|
+
// White (not typically logged directly)
|
|
1675
|
+
COORDINATOR: "#ff9800",
|
|
1676
|
+
// Orange
|
|
1677
|
+
LAYOUT: "#2196f3",
|
|
1678
|
+
// Blue
|
|
1679
|
+
TELEPORT: "#e91e63",
|
|
1680
|
+
// Pink
|
|
1681
|
+
VISUALS: "#9c27b0",
|
|
1682
|
+
// Purple
|
|
1683
|
+
NAV: "#4caf50",
|
|
1684
|
+
// Green
|
|
1685
|
+
INIT: "#f44336",
|
|
1686
|
+
// Red
|
|
1687
|
+
CACHE: "#795548",
|
|
1688
|
+
// Brown
|
|
1689
|
+
INTERACT: "#607d8b",
|
|
1690
|
+
// Blue Grey
|
|
1691
|
+
PERF: "#ff5722"
|
|
1692
|
+
// Deep Orange
|
|
1693
|
+
};
|
|
1694
|
+
var LoggerRegistry = class _LoggerRegistry {
|
|
1695
|
+
constructor() {
|
|
1696
|
+
this.globalHistory = [];
|
|
1697
|
+
this.counter = 0;
|
|
1698
|
+
this.maxGlobalHistory = 500;
|
|
1699
|
+
if (typeof window !== "undefined") {
|
|
1700
|
+
;
|
|
1701
|
+
window.__DUMP_CAROUSEL_LOGS = this.dump.bind(this);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
static getInstance() {
|
|
1705
|
+
if (!_LoggerRegistry.instance) {
|
|
1706
|
+
_LoggerRegistry.instance = new _LoggerRegistry();
|
|
1707
|
+
}
|
|
1708
|
+
return _LoggerRegistry.instance;
|
|
1709
|
+
}
|
|
1710
|
+
/** Get next unique ID for log entries */
|
|
1711
|
+
getNextId() {
|
|
1712
|
+
return ++this.counter;
|
|
1713
|
+
}
|
|
1714
|
+
/** Add entry to global history */
|
|
1715
|
+
addToGlobal(entry) {
|
|
1716
|
+
this.globalHistory.push(entry);
|
|
1717
|
+
if (this.globalHistory.length > this.maxGlobalHistory) {
|
|
1718
|
+
this.globalHistory.shift();
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Dump history to console as a table.
|
|
1723
|
+
* @param filterId - Optional: filter to only show logs from a specific carousel ID
|
|
1724
|
+
*/
|
|
1725
|
+
dump(filterId) {
|
|
1726
|
+
let entries = this.globalHistory;
|
|
1727
|
+
if (filterId) {
|
|
1728
|
+
entries = entries.filter((e) => e.carouselId === filterId);
|
|
1729
|
+
}
|
|
1730
|
+
if (entries.length === 0) {
|
|
1731
|
+
console.warn(`[CarouselLogger] No logs found${filterId ? ` for "${filterId}"` : ""}`);
|
|
1732
|
+
return "No logs found";
|
|
1733
|
+
}
|
|
1734
|
+
console.group(`\u{1F4F8} Carousel Debug Snapshot${filterId ? ` [${filterId}]` : " (all)"}`);
|
|
1735
|
+
console.table(
|
|
1736
|
+
entries.map((entry) => ({
|
|
1737
|
+
Time: new Date(entry.timestamp).toISOString().split("T")[1],
|
|
1738
|
+
ID: entry.carouselId,
|
|
1739
|
+
Channel: entry.channel,
|
|
1740
|
+
Message: entry.message,
|
|
1741
|
+
Data: entry.data ? JSON.stringify(entry.data) : ""
|
|
1742
|
+
}))
|
|
1743
|
+
);
|
|
1744
|
+
console.groupEnd();
|
|
1745
|
+
return `Dumped ${entries.length} logs.`;
|
|
1746
|
+
}
|
|
1747
|
+
/** Clear all global history */
|
|
1748
|
+
clear() {
|
|
1749
|
+
this.globalHistory = [];
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
var CarouselLoggerInstance = class {
|
|
1753
|
+
constructor(id, config) {
|
|
1754
|
+
this.localHistory = [];
|
|
1755
|
+
this.id = id;
|
|
1756
|
+
this.maxLocalHistory = config?.bufferSize ?? 100;
|
|
1757
|
+
this.channelOverrides = config?.channels;
|
|
1758
|
+
this.registry = LoggerRegistry.getInstance();
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Check if a channel should log to console.
|
|
1762
|
+
* Resolution order: instance override → global config
|
|
1763
|
+
*/
|
|
1764
|
+
shouldLog(channel) {
|
|
1765
|
+
const forceEnable = this.channelOverrides !== void 0;
|
|
1766
|
+
if (!DEBUG_CONFIG.ENABLED && !forceEnable) return false;
|
|
1767
|
+
if (this.channelOverrides !== void 0) {
|
|
1768
|
+
if (this.channelOverrides === "ALL") return true;
|
|
1769
|
+
if (this.channelOverrides[channel] !== void 0) {
|
|
1770
|
+
return this.channelOverrides[channel];
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (DEBUG_CONFIG.CHANNELS.ALL) return true;
|
|
1774
|
+
return DEBUG_CONFIG.CHANNELS[channel] ?? false;
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Log a message to history buffers and optionally to console.
|
|
1778
|
+
*/
|
|
1779
|
+
log(channel, message, data) {
|
|
1780
|
+
const forceEnable = this.channelOverrides !== void 0;
|
|
1781
|
+
if (!DEBUG_CONFIG.ENABLED && !forceEnable) return;
|
|
1782
|
+
const entry = {
|
|
1783
|
+
id: this.registry.getNextId(),
|
|
1784
|
+
timestamp: Date.now(),
|
|
1785
|
+
carouselId: this.id,
|
|
1786
|
+
channel,
|
|
1787
|
+
message,
|
|
1788
|
+
data
|
|
1789
|
+
};
|
|
1790
|
+
this.localHistory.push(entry);
|
|
1791
|
+
if (this.localHistory.length > this.maxLocalHistory) {
|
|
1792
|
+
this.localHistory.shift();
|
|
1793
|
+
}
|
|
1794
|
+
this.registry.addToGlobal(entry);
|
|
1795
|
+
if (this.shouldLog(channel) && (process.env.NODE_ENV !== "production" || forceEnable)) {
|
|
1796
|
+
const color = CHANNEL_COLORS[channel] || "#888";
|
|
1797
|
+
console.log(
|
|
1798
|
+
`%c[${this.id}]%c[${channel}]%c ${message}`,
|
|
1799
|
+
"color: #888; font-weight: bold",
|
|
1800
|
+
`color: ${color}; font-weight: bold`,
|
|
1801
|
+
"color: inherit",
|
|
1802
|
+
data ?? ""
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/**
|
|
1807
|
+
* Dump this instance's local history to console.
|
|
1808
|
+
*/
|
|
1809
|
+
dump() {
|
|
1810
|
+
if (this.localHistory.length === 0) {
|
|
1811
|
+
console.warn(`[CarouselLogger:${this.id}] Local buffer is empty`);
|
|
1812
|
+
return "No logs found";
|
|
1813
|
+
}
|
|
1814
|
+
console.group(`\u{1F4F8} Carousel Debug Snapshot [${this.id}]`);
|
|
1815
|
+
console.table(
|
|
1816
|
+
this.localHistory.map((entry) => ({
|
|
1817
|
+
Time: new Date(entry.timestamp).toISOString().split("T")[1],
|
|
1818
|
+
Channel: entry.channel,
|
|
1819
|
+
Message: entry.message,
|
|
1820
|
+
Data: entry.data ? JSON.stringify(entry.data) : ""
|
|
1821
|
+
}))
|
|
1822
|
+
);
|
|
1823
|
+
console.groupEnd();
|
|
1824
|
+
return `Dumped ${this.localHistory.length} logs.`;
|
|
1825
|
+
}
|
|
1826
|
+
/** Clear local history */
|
|
1827
|
+
clear() {
|
|
1828
|
+
this.localHistory = [];
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* Create a performance timer for tracking elapsed time.
|
|
1832
|
+
* Usage:
|
|
1833
|
+
* const timer = logger.createTimer()
|
|
1834
|
+
* // ... do work ...
|
|
1835
|
+
* logger.log('INIT', 'Completed', { elapsedMs: timer.elapsed() })
|
|
1836
|
+
*/
|
|
1837
|
+
createTimer() {
|
|
1838
|
+
let startTime = performance.now();
|
|
1839
|
+
return {
|
|
1840
|
+
elapsed: () => Math.round(performance.now() - startTime),
|
|
1841
|
+
reset: () => {
|
|
1842
|
+
startTime = performance.now();
|
|
1843
|
+
}
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
function createLogger(id, config) {
|
|
1848
|
+
return new CarouselLoggerInstance(id, config);
|
|
1849
|
+
}
|
|
1850
|
+
var carouselLogger = createLogger("default");
|
|
1851
|
+
|
|
1852
|
+
// src/Carousel.tsx
|
|
1853
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
1854
|
+
var SSR_FALLBACK_WIDTH = 170;
|
|
1855
|
+
var CSS_VAR_WITH_FALLBACK = {
|
|
1856
|
+
default: "var(--carousel-item-width-default, 200px)",
|
|
1857
|
+
review: "var(--carousel-item-width-review, 280px)",
|
|
1858
|
+
compact: "var(--carousel-item-width-compact, 150px)",
|
|
1859
|
+
collection: "var(--carousel-item-width-collection, 100px)",
|
|
1860
|
+
wide: "var(--carousel-item-width-wide, 350px)"
|
|
1861
|
+
};
|
|
1862
|
+
var CSS_VAR_MAP = {
|
|
1863
|
+
default: "--carousel-item-width-default",
|
|
1864
|
+
review: "--carousel-item-width-review",
|
|
1865
|
+
compact: "--carousel-item-width-compact",
|
|
1866
|
+
collection: "--carousel-item-width-collection",
|
|
1867
|
+
wide: "--carousel-item-width-wide"
|
|
1868
|
+
};
|
|
1869
|
+
var getComputedItemWidth = (cssVarOrName = "default", fallback = SSR_FALLBACK_WIDTH) => {
|
|
1870
|
+
if (typeof window === "undefined") return fallback;
|
|
1871
|
+
const cssVar = cssVarOrName.startsWith("--") ? cssVarOrName : CSS_VAR_MAP[cssVarOrName];
|
|
1872
|
+
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar);
|
|
1873
|
+
return parseInt(value, 10) || fallback;
|
|
1874
|
+
};
|
|
1875
|
+
function BaseCarouselInner({
|
|
1876
|
+
items,
|
|
1877
|
+
getItemKey,
|
|
1878
|
+
renderItem,
|
|
1879
|
+
infinite = false,
|
|
1880
|
+
onEndReached,
|
|
1881
|
+
hasNextPage = false,
|
|
1882
|
+
itemWidthVar = "default",
|
|
1883
|
+
itemWidthCssVar,
|
|
1884
|
+
fallbackWidth = 200,
|
|
1885
|
+
itemClassName = "",
|
|
1886
|
+
snapType = "mandatory",
|
|
1887
|
+
disableOpacityEffect = false,
|
|
1888
|
+
disableScaleEffect = false,
|
|
1889
|
+
verticalPadding = "20px",
|
|
1890
|
+
snap = true,
|
|
1891
|
+
renderSkeleton,
|
|
1892
|
+
persistKey,
|
|
1893
|
+
onActiveItemChange,
|
|
1894
|
+
gap: gapProp,
|
|
1895
|
+
debugId = "carousel",
|
|
1896
|
+
debug,
|
|
1897
|
+
eagerSelectionOnMobile = false,
|
|
1898
|
+
initialIndex
|
|
1899
|
+
}) {
|
|
1900
|
+
const resolvedGap = gapProp ?? (typeof window !== "undefined" && window.innerWidth < LAYOUT_CONFIG.GAP_BREAKPOINT ? LAYOUT_CONFIG.GAP_MOBILE : LAYOUT_CONFIG.GAP_DESKTOP);
|
|
1901
|
+
const logger = (0, import_react10.useMemo)(() => createLogger(debugId, debug), [debugId, debug]);
|
|
1902
|
+
const widthCssValue = itemWidthCssVar ? `var(${itemWidthCssVar}, ${fallbackWidth}px)` : CSS_VAR_WITH_FALLBACK[itemWidthVar];
|
|
1903
|
+
const widthCssVar = itemWidthCssVar || CSS_VAR_MAP[itemWidthVar];
|
|
1904
|
+
const initTimerRef = (0, import_react10.useRef)(logger.createTimer());
|
|
1905
|
+
const getElapsedMs = () => initTimerRef.current.elapsed();
|
|
1906
|
+
const { isReady, showSkeleton, markReady, isInstant } = useLoadingState({
|
|
1907
|
+
cacheKey: persistKey ? `carousel-${persistKey}` : void 0,
|
|
1908
|
+
skeletonDelay: 50,
|
|
1909
|
+
fallbackTimeout: 3e3
|
|
1910
|
+
});
|
|
1911
|
+
const { getSavedPosition, savePosition } = useCarouselPersistence({
|
|
1912
|
+
persistKey,
|
|
1913
|
+
debounceMs: 150
|
|
1914
|
+
});
|
|
1915
|
+
const handleEndReached = () => {
|
|
1916
|
+
if (hasNextPage && onEndReached) {
|
|
1917
|
+
onEndReached();
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
const { ref: draggableRef, isDragging, events, cancelMomentum, adjustScroll } = useDraggableScroll({
|
|
1921
|
+
infinite,
|
|
1922
|
+
onEndReached: handleEndReached,
|
|
1923
|
+
hasNextPage,
|
|
1924
|
+
cardWidth: LAYOUT_CONFIG.INITIAL_CARD_WIDTH,
|
|
1925
|
+
gap: resolvedGap
|
|
1926
|
+
});
|
|
1927
|
+
const { layout, measureLayout: triggerLayoutMeasure, resizeCount, isMobile } = useCarouselLayout({
|
|
1928
|
+
containerRef: draggableRef,
|
|
1929
|
+
logger
|
|
1930
|
+
});
|
|
1931
|
+
const { clonesBefore, clonesAfter } = (0, import_react10.useMemo)(() => {
|
|
1932
|
+
let before = [];
|
|
1933
|
+
let after = [];
|
|
1934
|
+
if (infinite && items.length > 0) {
|
|
1935
|
+
const itemsNeeded = Math.ceil(LAYOUT_CONFIG.MIN_BUFFER_COUNT / items.length);
|
|
1936
|
+
const count = Math.max(1, itemsNeeded);
|
|
1937
|
+
before = Array(count).fill(items).flat();
|
|
1938
|
+
after = Array(count).fill(items).flat();
|
|
1939
|
+
}
|
|
1940
|
+
return { clonesBefore: before, clonesAfter: after };
|
|
1941
|
+
}, [infinite, items]);
|
|
1942
|
+
const allItems = (0, import_react10.useMemo)(() => [...clonesBefore, ...items, ...clonesAfter], [clonesBefore, items, clonesAfter]);
|
|
1943
|
+
const bufferBeforeCount = clonesBefore.length;
|
|
1944
|
+
const { transition, getPhase, getContext, contextRef } = useCarouselCoordinator({ logger });
|
|
1945
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? import_react10.useLayoutEffect : import_react10.useEffect;
|
|
1946
|
+
const hasInitialized = (0, import_react10.useRef)(false);
|
|
1947
|
+
const scrollEndListenerRef = (0, import_react10.useRef)(null);
|
|
1948
|
+
const snapTimeoutRef = (0, import_react10.useRef)(null);
|
|
1949
|
+
const scrollIdleTimeoutRef = (0, import_react10.useRef)(null);
|
|
1950
|
+
const lastActiveItemRef = (0, import_react10.useRef)(null);
|
|
1951
|
+
const lastInitRef = (0, import_react10.useRef)(null);
|
|
1952
|
+
const {
|
|
1953
|
+
childrenPositions,
|
|
1954
|
+
isCacheDirty,
|
|
1955
|
+
containerWidthRef,
|
|
1956
|
+
isContainerWidthDirty,
|
|
1957
|
+
updateCache,
|
|
1958
|
+
applyVisuals
|
|
1959
|
+
} = useCarouselVisuals({
|
|
1960
|
+
layout,
|
|
1961
|
+
itemsCount: items.length,
|
|
1962
|
+
bufferBeforeCount,
|
|
1963
|
+
disableOpacityEffect,
|
|
1964
|
+
disableScaleEffect,
|
|
1965
|
+
logger
|
|
1966
|
+
});
|
|
1967
|
+
const { preTeleport } = useCarouselTeleport({
|
|
1968
|
+
containerRef: draggableRef,
|
|
1969
|
+
infinite,
|
|
1970
|
+
itemsCount: items.length,
|
|
1971
|
+
cardWidth: layout.cardWidth,
|
|
1972
|
+
gap: layout.gap,
|
|
1973
|
+
bufferBeforeCount,
|
|
1974
|
+
applyVisuals,
|
|
1975
|
+
adjustScroll,
|
|
1976
|
+
preTeleportClearDelayMs: TIMING_CONFIG.PRE_TELEPORT_CLEAR_DELAY_MS,
|
|
1977
|
+
coordinator: {
|
|
1978
|
+
transition,
|
|
1979
|
+
getContext,
|
|
1980
|
+
getPhase,
|
|
1981
|
+
contextRef,
|
|
1982
|
+
isBusy: () => getPhase() !== "IDLE",
|
|
1983
|
+
isBlocking: () => getPhase() === "BOUNCING" || getPhase() === "TELEPORTING"
|
|
1984
|
+
},
|
|
1985
|
+
logger
|
|
1986
|
+
});
|
|
1987
|
+
const initializeCarousel = (0, import_react10.useCallback)((node) => {
|
|
1988
|
+
if (items.length === 0) {
|
|
1989
|
+
if (!isReady) markReady();
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
let cardWidth;
|
|
1993
|
+
let gap;
|
|
1994
|
+
if (hasInitialized.current) {
|
|
1995
|
+
cardWidth = layout.cardWidth;
|
|
1996
|
+
gap = layout.gap;
|
|
1997
|
+
} else {
|
|
1998
|
+
const measured = triggerLayoutMeasure();
|
|
1999
|
+
cardWidth = measured.cardWidth;
|
|
2000
|
+
gap = measured.gap;
|
|
2001
|
+
}
|
|
2002
|
+
const expectedWidth = getComputedItemWidth(widthCssVar, fallbackWidth);
|
|
2003
|
+
const widthDiff = Math.abs(cardWidth - expectedWidth);
|
|
2004
|
+
const TOLERANCE = 10;
|
|
2005
|
+
const hasNoChildren = node.children.length === 0;
|
|
2006
|
+
const isNotExpectedWidth = widthDiff > TOLERANCE;
|
|
2007
|
+
const hasScrollableContent = node.scrollWidth > node.clientWidth;
|
|
2008
|
+
const isUnmeasured = hasNoChildren || isNotExpectedWidth || !hasScrollableContent;
|
|
2009
|
+
if (isUnmeasured && !hasInitialized.current) {
|
|
2010
|
+
logger.log("INIT", "Skipping - not ready for initialization", {
|
|
2011
|
+
elapsedMs: getElapsedMs(),
|
|
2012
|
+
childrenCount: node.children.length,
|
|
2013
|
+
cardWidth,
|
|
2014
|
+
expectedWidth,
|
|
2015
|
+
widthDiff,
|
|
2016
|
+
itemWidthVar,
|
|
2017
|
+
hasScrollableContent,
|
|
2018
|
+
scrollWidth: node.scrollWidth,
|
|
2019
|
+
clientWidth: node.clientWidth,
|
|
2020
|
+
gap
|
|
2021
|
+
});
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
if (hasInitialized.current && lastInitRef.current && lastInitRef.current.cardWidth === cardWidth && lastInitRef.current.gap === gap) {
|
|
2025
|
+
logger.log("INIT", "Skipping - layout unchanged", { cardWidth, gap });
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
lastInitRef.current = { cardWidth, gap };
|
|
2029
|
+
const stride = cardWidth + gap;
|
|
2030
|
+
let targetPos;
|
|
2031
|
+
if (hasInitialized.current) {
|
|
2032
|
+
const currentIndex = Math.round(node.scrollLeft / stride);
|
|
2033
|
+
targetPos = currentIndex * stride;
|
|
2034
|
+
logger.log("INIT", `Re-initializing (Resize/Update)`, {
|
|
2035
|
+
currentIndex,
|
|
2036
|
+
targetPos,
|
|
2037
|
+
prevScroll: node.scrollLeft,
|
|
2038
|
+
stride
|
|
2039
|
+
});
|
|
2040
|
+
} else {
|
|
2041
|
+
const savedPosition = getSavedPosition();
|
|
2042
|
+
if (savedPosition !== null) {
|
|
2043
|
+
const maxScroll = Math.max(0, node.scrollWidth - node.clientWidth);
|
|
2044
|
+
targetPos = Math.min(savedPosition, maxScroll);
|
|
2045
|
+
logger.log("CACHE", `Restoring scroll position`, { saved: savedPosition, clamped: targetPos, maxScroll });
|
|
2046
|
+
} else {
|
|
2047
|
+
const startIdx = typeof initialIndex === "number" ? initialIndex : 0;
|
|
2048
|
+
const targetIndex = infinite ? bufferBeforeCount + startIdx : startIdx;
|
|
2049
|
+
const targetNode = node.children[targetIndex];
|
|
2050
|
+
if (targetNode) {
|
|
2051
|
+
const itemCenter = targetNode.offsetLeft + targetNode.offsetWidth / 2;
|
|
2052
|
+
const containerCenter = node.clientWidth / 2;
|
|
2053
|
+
targetPos = Math.max(0, itemCenter - containerCenter);
|
|
2054
|
+
logger.log("INIT", "DOM-based positioning used", {
|
|
2055
|
+
targetIndex,
|
|
2056
|
+
itemCenter,
|
|
2057
|
+
containerCenter,
|
|
2058
|
+
targetPos,
|
|
2059
|
+
offsetLeft: targetNode.offsetLeft,
|
|
2060
|
+
initialIndex
|
|
2061
|
+
});
|
|
2062
|
+
} else {
|
|
2063
|
+
const startIdx2 = typeof initialIndex === "number" ? initialIndex : 0;
|
|
2064
|
+
const targetIdx = infinite ? bufferBeforeCount + startIdx2 : startIdx2;
|
|
2065
|
+
targetPos = targetIdx * stride;
|
|
2066
|
+
logger.log("INIT", "Fallback to theoretical positioning", { targetPos, initialIndex });
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
const initialTargetIndex = targetPos / stride;
|
|
2070
|
+
logger.log("INIT", "Target Calc Trace", {
|
|
2071
|
+
targetPos,
|
|
2072
|
+
stride,
|
|
2073
|
+
initialTargetIndex,
|
|
2074
|
+
bufferBeforeCount,
|
|
2075
|
+
infinite
|
|
2076
|
+
});
|
|
2077
|
+
}
|
|
2078
|
+
const positionDrift = Math.abs(node.scrollLeft - targetPos);
|
|
2079
|
+
const needsCorrection = !hasInitialized.current || positionDrift > stride / 2;
|
|
2080
|
+
logger.log("INIT", "Target Calc Result", {
|
|
2081
|
+
targetPos,
|
|
2082
|
+
currentScroll: node.scrollLeft,
|
|
2083
|
+
needsCorrection,
|
|
2084
|
+
hasInitialized: hasInitialized.current,
|
|
2085
|
+
infinite,
|
|
2086
|
+
bufferBeforeCount
|
|
2087
|
+
});
|
|
2088
|
+
if (needsCorrection) {
|
|
2089
|
+
logger.log("INIT", `Applying position correction`, {
|
|
2090
|
+
elapsedMs: getElapsedMs(),
|
|
2091
|
+
current: node.scrollLeft,
|
|
2092
|
+
target: targetPos,
|
|
2093
|
+
drift: positionDrift,
|
|
2094
|
+
firstInit: !hasInitialized.current
|
|
2095
|
+
});
|
|
2096
|
+
node.style.scrollSnapType = "none";
|
|
2097
|
+
if (infinite) {
|
|
2098
|
+
const centerPadding = `calc(50% - ${cardWidth / 2}px)`;
|
|
2099
|
+
node.style.paddingLeft = centerPadding;
|
|
2100
|
+
node.style.paddingRight = centerPadding;
|
|
2101
|
+
node.style.scrollPaddingLeft = centerPadding;
|
|
2102
|
+
node.style.scrollPaddingRight = centerPadding;
|
|
2103
|
+
}
|
|
2104
|
+
logger.log("INIT", "DRIFT DEBUG: Before scrollLeft set", {
|
|
2105
|
+
currentScroll: node.scrollLeft,
|
|
2106
|
+
targetPos,
|
|
2107
|
+
paddingLeft: node.style.paddingLeft,
|
|
2108
|
+
clientWidth: node.clientWidth
|
|
2109
|
+
});
|
|
2110
|
+
node.scrollLeft = targetPos;
|
|
2111
|
+
logger.log("INIT", "DRIFT DEBUG: After scrollLeft set (before flush)", {
|
|
2112
|
+
scrollLeftNow: node.scrollLeft
|
|
2113
|
+
});
|
|
2114
|
+
void node.offsetHeight;
|
|
2115
|
+
logger.log("INIT", "DRIFT DEBUG: After layout flush (before snap re-enable)", {
|
|
2116
|
+
scrollLeftNow: node.scrollLeft
|
|
2117
|
+
});
|
|
2118
|
+
node.style.scrollSnapType = "";
|
|
2119
|
+
logger.log("INIT", "DRIFT DEBUG: After snap re-enabled", {
|
|
2120
|
+
scrollLeftNow: node.scrollLeft
|
|
2121
|
+
});
|
|
2122
|
+
}
|
|
2123
|
+
applyVisuals(node);
|
|
2124
|
+
node.style.opacity = "1";
|
|
2125
|
+
if (!hasInitialized.current) {
|
|
2126
|
+
hasInitialized.current = true;
|
|
2127
|
+
transition({ type: "INITIALIZE" });
|
|
2128
|
+
}
|
|
2129
|
+
if (!isReady) markReady();
|
|
2130
|
+
}, [items.length, bufferBeforeCount, applyVisuals, isReady, infinite, markReady, layout.cardWidth, layout.gap, triggerLayoutMeasure, transition, getSavedPosition, resizeCount, itemWidthVar, initialIndex]);
|
|
2131
|
+
const setCarouselRef = (0, import_react10.useCallback)((node) => {
|
|
2132
|
+
if (node) {
|
|
2133
|
+
draggableRef.current = node;
|
|
2134
|
+
initializeCarousel(node);
|
|
2135
|
+
}
|
|
2136
|
+
}, [draggableRef, initializeCarousel]);
|
|
2137
|
+
useIsomorphicLayoutEffect(() => {
|
|
2138
|
+
const node = draggableRef.current;
|
|
2139
|
+
if (node && items.length > 0) {
|
|
2140
|
+
initializeCarousel(node);
|
|
2141
|
+
}
|
|
2142
|
+
}, [items.length, initializeCarousel, draggableRef, resizeCount]);
|
|
2143
|
+
(0, import_react10.useEffect)(() => {
|
|
2144
|
+
if (draggableRef.current) {
|
|
2145
|
+
isCacheDirty.current = true;
|
|
2146
|
+
isContainerWidthDirty.current = true;
|
|
2147
|
+
updateCache(draggableRef.current);
|
|
2148
|
+
requestAnimationFrame(() => applyVisuals(draggableRef.current));
|
|
2149
|
+
}
|
|
2150
|
+
}, [layout.cardWidth, layout.gap, updateCache, applyVisuals, draggableRef, isCacheDirty, isContainerWidthDirty]);
|
|
2151
|
+
(0, import_react10.useEffect)(() => {
|
|
2152
|
+
const el = draggableRef.current;
|
|
2153
|
+
if (!el || !persistKey) return;
|
|
2154
|
+
const handleScrollEnd = () => {
|
|
2155
|
+
savePosition(el.scrollLeft);
|
|
2156
|
+
};
|
|
2157
|
+
el.addEventListener("scrollend", handleScrollEnd);
|
|
2158
|
+
return () => el.removeEventListener("scrollend", handleScrollEnd);
|
|
2159
|
+
}, [draggableRef, persistKey, savePosition]);
|
|
2160
|
+
const getActiveItemAtScroll = (0, import_react10.useCallback)((scrollLeft2, direction = 0, overrides) => {
|
|
2161
|
+
const activeCardWidth = overrides?.cardWidth ?? layout.cardWidth;
|
|
2162
|
+
const activeGap = overrides?.gap ?? layout.gap;
|
|
2163
|
+
const stride = activeCardWidth + activeGap;
|
|
2164
|
+
if (stride <= 0 || items.length === 0) return null;
|
|
2165
|
+
const activeStride = layout.domStride > 0 ? layout.domStride : stride;
|
|
2166
|
+
const effectiveScroll = scrollLeft2;
|
|
2167
|
+
const rawIndex = effectiveScroll / activeStride;
|
|
2168
|
+
let totalIndex;
|
|
2169
|
+
logger.log("NAV", "getActiveItemAtScroll START", {
|
|
2170
|
+
scrollLeft: scrollLeft2,
|
|
2171
|
+
effectiveScroll,
|
|
2172
|
+
calculatedStride: stride,
|
|
2173
|
+
domStride: activeStride,
|
|
2174
|
+
rawIndex,
|
|
2175
|
+
direction,
|
|
2176
|
+
overrides
|
|
2177
|
+
});
|
|
2178
|
+
const EAGER_THRESHOLD = isMobile && eagerSelectionOnMobile ? 0.3 : 0.5;
|
|
2179
|
+
if (direction > 0) {
|
|
2180
|
+
totalIndex = Math.floor(rawIndex + (1 - EAGER_THRESHOLD));
|
|
2181
|
+
} else if (direction < 0) {
|
|
2182
|
+
totalIndex = Math.ceil(rawIndex - (1 - EAGER_THRESHOLD));
|
|
2183
|
+
} else {
|
|
2184
|
+
totalIndex = Math.round(rawIndex);
|
|
2185
|
+
}
|
|
2186
|
+
let activeIndex;
|
|
2187
|
+
if (infinite) {
|
|
2188
|
+
activeIndex = ((totalIndex - bufferBeforeCount) % items.length + items.length) % items.length;
|
|
2189
|
+
} else {
|
|
2190
|
+
activeIndex = Math.max(0, Math.min(totalIndex, items.length - 1));
|
|
2191
|
+
}
|
|
2192
|
+
logger.log("NAV", "getActiveItemAtScroll RESULT", {
|
|
2193
|
+
scrollLeft: scrollLeft2,
|
|
2194
|
+
stride,
|
|
2195
|
+
rawIndex,
|
|
2196
|
+
totalIndex,
|
|
2197
|
+
activeIndex,
|
|
2198
|
+
bufferBeforeCount,
|
|
2199
|
+
itemId: items[activeIndex]?.id,
|
|
2200
|
+
title: items[activeIndex]?.title
|
|
2201
|
+
});
|
|
2202
|
+
return items[activeIndex] || null;
|
|
2203
|
+
}, [layout.cardWidth, layout.gap, layout.domStride, items, infinite, bufferBeforeCount, isMobile, eagerSelectionOnMobile]);
|
|
2204
|
+
const onScrollToItemComplete = (0, import_react10.useCallback)((source) => {
|
|
2205
|
+
const el = draggableRef.current;
|
|
2206
|
+
if (!el) return;
|
|
2207
|
+
const ctx = getContext();
|
|
2208
|
+
if (ctx.pendingTarget === null && source !== "safety-timeout") {
|
|
2209
|
+
logger.log("NAV", `ScrollToItem found pending=null (interrupted) via ${source}`);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
if (ctx.isPreTeleporting) {
|
|
2213
|
+
logger.log("NAV", `ScrollToItem finished but isPreTeleporting=true, skipping cleanup`);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
logger.log("NAV", `ScrollToItem complete via ${source}!`);
|
|
2217
|
+
transition({ type: "SCROLL_COMPLETE" });
|
|
2218
|
+
if (infinite && el) {
|
|
2219
|
+
el.style.scrollSnapType = "";
|
|
2220
|
+
logger.log("NAV", `Snap restored after ScrollToItem`);
|
|
2221
|
+
}
|
|
2222
|
+
}, [infinite, draggableRef, getContext, transition]);
|
|
2223
|
+
const { waitForScrollCompletion: waitForScrollCompletionForClick } = useScrollCompletion({
|
|
2224
|
+
ref: draggableRef,
|
|
2225
|
+
onComplete: onScrollToItemComplete,
|
|
2226
|
+
// We can reuse the snapTimeoutRef for safety, or let the hook manage its own.
|
|
2227
|
+
// Since scrollToThisItem used snapTimeoutRef as "safety net" before, we can pass it.
|
|
2228
|
+
// BUT wait, snapTimeoutRef is usually for restoring snap.
|
|
2229
|
+
// Check legacy code: "snapTimeoutRef.current = setTimeout(...)". Yes, it was reusing that ref.
|
|
2230
|
+
timeoutRef: snapTimeoutRef
|
|
2231
|
+
});
|
|
2232
|
+
const scrollToThisItem = (0, import_react10.useCallback)((index) => {
|
|
2233
|
+
const el = draggableRef.current;
|
|
2234
|
+
if (!el) return;
|
|
2235
|
+
const stride = layout.cardWidth + layout.gap;
|
|
2236
|
+
if (stride <= 0) return;
|
|
2237
|
+
let targetScroll = index * stride;
|
|
2238
|
+
if (infinite && el.children[index]) {
|
|
2239
|
+
const targetNode = el.children[index];
|
|
2240
|
+
const itemCenter = targetNode.offsetLeft + targetNode.offsetWidth / 2;
|
|
2241
|
+
const containerCenter = el.clientWidth / 2;
|
|
2242
|
+
targetScroll = Math.max(0, itemCenter - containerCenter);
|
|
2243
|
+
logger.log("INTERACT", `Calculated DOM target for click`, { index, itemCenter, containerCenter, targetScroll });
|
|
2244
|
+
}
|
|
2245
|
+
if (infinite) {
|
|
2246
|
+
el.style.scrollSnapType = "none";
|
|
2247
|
+
}
|
|
2248
|
+
const thisClickId = Math.floor(Math.random() * 1e3);
|
|
2249
|
+
logger.log("INTERACT", `\u2501\u2501\u2501 Item Click #${thisClickId} START \u2501\u2501\u2501`, { index, targetScroll });
|
|
2250
|
+
transition({ type: "ITEM_CLICK", targetScroll });
|
|
2251
|
+
el.scrollTo({
|
|
2252
|
+
left: targetScroll,
|
|
2253
|
+
behavior: "smooth"
|
|
2254
|
+
});
|
|
2255
|
+
const targetItem = getActiveItemAtScroll(targetScroll);
|
|
2256
|
+
if (targetItem && onActiveItemChange) {
|
|
2257
|
+
lastActiveItemRef.current = targetItem;
|
|
2258
|
+
onActiveItemChange(targetItem);
|
|
2259
|
+
}
|
|
2260
|
+
waitForScrollCompletionForClick();
|
|
2261
|
+
}, [layout, draggableRef, infinite, getActiveItemAtScroll, onActiveItemChange, waitForScrollCompletionForClick, transition]);
|
|
2262
|
+
const { handleScrollNav, scrollLeft, scrollRight } = useCarouselNavigation({
|
|
2263
|
+
containerRef: draggableRef,
|
|
2264
|
+
infinite: !!infinite,
|
|
2265
|
+
layout: { cardWidth: layout.cardWidth, gap: layout.gap },
|
|
2266
|
+
cancelMomentum,
|
|
2267
|
+
preTeleport,
|
|
2268
|
+
coordinator: { transition, getPhase, getContext, contextRef, isBusy: () => getPhase() !== "IDLE", isBlocking: () => getPhase() === "BOUNCING" || getPhase() === "TELEPORTING" },
|
|
2269
|
+
onNavigate: (targetScroll) => {
|
|
2270
|
+
const targetItem = getActiveItemAtScroll(targetScroll);
|
|
2271
|
+
if (targetItem && onActiveItemChange) {
|
|
2272
|
+
lastActiveItemRef.current = targetItem;
|
|
2273
|
+
onActiveItemChange(targetItem);
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
logger
|
|
2277
|
+
});
|
|
2278
|
+
const activeItemCallbackRef = (0, import_react10.useRef)(onActiveItemChange);
|
|
2279
|
+
activeItemCallbackRef.current = onActiveItemChange;
|
|
2280
|
+
const getterRef = (0, import_react10.useRef)(getActiveItemAtScroll);
|
|
2281
|
+
getterRef.current = getActiveItemAtScroll;
|
|
2282
|
+
const lastScrollLeftRef = (0, import_react10.useRef)(0);
|
|
2283
|
+
const lastMeaningfulDirectionRef = (0, import_react10.useRef)(0);
|
|
2284
|
+
(0, import_react10.useEffect)(() => {
|
|
2285
|
+
const currentCallback = activeItemCallbackRef.current;
|
|
2286
|
+
if (!currentCallback || items.length === 0) return;
|
|
2287
|
+
const el = draggableRef.current;
|
|
2288
|
+
if (!el) return;
|
|
2289
|
+
let timeoutId;
|
|
2290
|
+
const emitActiveItem = (scrollLeft2) => {
|
|
2291
|
+
const ctx = contextRef.current;
|
|
2292
|
+
if (ctx.isTeleporting) {
|
|
2293
|
+
lastScrollLeftRef.current = scrollLeft2;
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (ctx.pendingTarget !== null) {
|
|
2297
|
+
lastScrollLeftRef.current = scrollLeft2;
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const delta = scrollLeft2 - lastScrollLeftRef.current;
|
|
2301
|
+
let direction = lastMeaningfulDirectionRef.current;
|
|
2302
|
+
if (Math.abs(delta) > 5) {
|
|
2303
|
+
direction = delta > 0 ? 1 : -1;
|
|
2304
|
+
lastMeaningfulDirectionRef.current = direction;
|
|
2305
|
+
}
|
|
2306
|
+
const activeItem = getterRef.current(scrollLeft2, direction);
|
|
2307
|
+
if (activeItem && activeItem !== lastActiveItemRef.current) {
|
|
2308
|
+
lastActiveItemRef.current = activeItem;
|
|
2309
|
+
if (activeItemCallbackRef.current) {
|
|
2310
|
+
activeItemCallbackRef.current(activeItem);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
lastScrollLeftRef.current = scrollLeft2;
|
|
2314
|
+
};
|
|
2315
|
+
const handleScrollEnd = () => {
|
|
2316
|
+
emitActiveItem(el.scrollLeft);
|
|
2317
|
+
};
|
|
2318
|
+
const handleScrollImmediate = () => {
|
|
2319
|
+
emitActiveItem(el.scrollLeft);
|
|
2320
|
+
};
|
|
2321
|
+
const supportsScrollEnd = typeof window !== "undefined" && "onscrollend" in window;
|
|
2322
|
+
const scrollFallbackListener = () => {
|
|
2323
|
+
clearTimeout(timeoutId);
|
|
2324
|
+
timeoutId = setTimeout(handleScrollEnd, 150);
|
|
2325
|
+
};
|
|
2326
|
+
if (supportsScrollEnd) {
|
|
2327
|
+
el.addEventListener("scrollend", handleScrollEnd);
|
|
2328
|
+
} else {
|
|
2329
|
+
el.addEventListener("scroll", scrollFallbackListener, { passive: true });
|
|
2330
|
+
}
|
|
2331
|
+
el.addEventListener("scroll", handleScrollImmediate, { passive: true });
|
|
2332
|
+
return () => {
|
|
2333
|
+
el.removeEventListener("scrollend", handleScrollEnd);
|
|
2334
|
+
el.removeEventListener("scroll", scrollFallbackListener);
|
|
2335
|
+
el.removeEventListener("scroll", handleScrollImmediate);
|
|
2336
|
+
clearTimeout(timeoutId);
|
|
2337
|
+
};
|
|
2338
|
+
}, [items.length]);
|
|
2339
|
+
useIsomorphicLayoutEffect(() => {
|
|
2340
|
+
const el = draggableRef.current;
|
|
2341
|
+
if (!el) return;
|
|
2342
|
+
if (isCacheDirty.current) {
|
|
2343
|
+
updateCache(el);
|
|
2344
|
+
isCacheDirty.current = false;
|
|
2345
|
+
}
|
|
2346
|
+
applyVisuals(el);
|
|
2347
|
+
});
|
|
2348
|
+
const lastInteractionRef = (0, import_react10.useRef)(0);
|
|
2349
|
+
const handleArrowClick = (direction) => {
|
|
2350
|
+
lastInteractionRef.current = Date.now();
|
|
2351
|
+
if (direction === "left") scrollLeft();
|
|
2352
|
+
else scrollRight();
|
|
2353
|
+
};
|
|
2354
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
|
|
2355
|
+
"div",
|
|
2356
|
+
{
|
|
2357
|
+
className: "base-carousel-container relative carousel-hover-group overflow-hidden",
|
|
2358
|
+
style: {
|
|
2359
|
+
paddingTop: verticalPadding,
|
|
2360
|
+
paddingBottom: verticalPadding
|
|
2361
|
+
},
|
|
2362
|
+
children: [
|
|
2363
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CarouselArrow, { direction: "left", onClick: () => handleArrowClick("left"), className: "prev" }),
|
|
2364
|
+
infinite && !isReady && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
2365
|
+
"div",
|
|
2366
|
+
{
|
|
2367
|
+
className: "absolute inset-0 z-10 flex gap-6 overflow-hidden pointer-events-none px-4",
|
|
2368
|
+
"aria-hidden": "true",
|
|
2369
|
+
style: {
|
|
2370
|
+
paddingTop: 0,
|
|
2371
|
+
paddingBottom: 0
|
|
2372
|
+
},
|
|
2373
|
+
children: Array.from({ length: 8 }).map((_, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
2374
|
+
"div",
|
|
2375
|
+
{
|
|
2376
|
+
className: `flex-shrink-0 ${itemClassName}`,
|
|
2377
|
+
style: { width: widthCssValue },
|
|
2378
|
+
children: renderSkeleton ? renderSkeleton(i) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "w-full h-full bg-gradient-to-br from-gray-100 via-gray-200 to-gray-100 animate-pulse rounded-md", style: { minHeight: "200px" } })
|
|
2379
|
+
},
|
|
2380
|
+
i
|
|
2381
|
+
))
|
|
2382
|
+
}
|
|
2383
|
+
),
|
|
2384
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
2385
|
+
"div",
|
|
2386
|
+
{
|
|
2387
|
+
onPointerDown: (e) => {
|
|
2388
|
+
const timeSinceInteraction = Date.now() - lastInteractionRef.current;
|
|
2389
|
+
if (timeSinceInteraction < 400) {
|
|
2390
|
+
logger.log("INTERACT", `Ignoring PointerDown during grace period (${timeSinceInteraction}ms)`);
|
|
2391
|
+
e.preventDefault();
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
const hadPendingScroll = contextRef.current.pendingTarget !== null;
|
|
2395
|
+
if (hadPendingScroll) {
|
|
2396
|
+
logger.log("INTERACT", "PointerDown: Interrupting programmatic scroll");
|
|
2397
|
+
transition({ type: "USER_INTERRUPT" });
|
|
2398
|
+
if (draggableRef.current) applyVisuals(draggableRef.current);
|
|
2399
|
+
}
|
|
2400
|
+
if (snapTimeoutRef.current) {
|
|
2401
|
+
logger.log("INTERACT", "PointerDown: Clearing snap timeout");
|
|
2402
|
+
clearTimeout(snapTimeoutRef.current);
|
|
2403
|
+
snapTimeoutRef.current = null;
|
|
2404
|
+
}
|
|
2405
|
+
if (draggableRef.current && e.pointerType === "touch") {
|
|
2406
|
+
draggableRef.current.style.scrollSnapType = "";
|
|
2407
|
+
}
|
|
2408
|
+
if (e.pointerType !== "touch" && draggableRef.current) {
|
|
2409
|
+
draggableRef.current.style.scrollSnapType = "none";
|
|
2410
|
+
}
|
|
2411
|
+
lastMeaningfulDirectionRef.current = 0;
|
|
2412
|
+
events.onPointerDown(e);
|
|
2413
|
+
},
|
|
2414
|
+
onWheel: () => {
|
|
2415
|
+
if (contextRef.current.pendingTarget !== null) {
|
|
2416
|
+
logger.log("INTERACT", "Wheel: Interrupting programmatic scroll");
|
|
2417
|
+
transition({ type: "USER_INTERRUPT" });
|
|
2418
|
+
if (draggableRef.current) draggableRef.current.style.scrollSnapType = "";
|
|
2419
|
+
}
|
|
2420
|
+
if (snapTimeoutRef.current) {
|
|
2421
|
+
logger.log("INTERACT", "Wheel: Clearing snap timeout");
|
|
2422
|
+
clearTimeout(snapTimeoutRef.current);
|
|
2423
|
+
snapTimeoutRef.current = null;
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
onTouchStart: () => {
|
|
2427
|
+
if (snapTimeoutRef.current) {
|
|
2428
|
+
clearTimeout(snapTimeoutRef.current);
|
|
2429
|
+
snapTimeoutRef.current = null;
|
|
2430
|
+
}
|
|
2431
|
+
},
|
|
2432
|
+
className: `base-carousel flex items-stretch overflow-x-auto overscroll-x-none scrollbar-hide select-none ${snap ? `snap-x snap-${snapType}` : ""}`,
|
|
2433
|
+
onPointerUp: events.onPointerUp,
|
|
2434
|
+
onPointerMove: events.onPointerMove,
|
|
2435
|
+
onLostPointerCapture: events.onLostPointerCapture,
|
|
2436
|
+
onClickCapture: events.onClickCapture,
|
|
2437
|
+
onDragStart: events.onDragStart,
|
|
2438
|
+
ref: setCarouselRef,
|
|
2439
|
+
style: {
|
|
2440
|
+
gap: `${resolvedGap}px`,
|
|
2441
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
2442
|
+
scrollBehavior: "auto",
|
|
2443
|
+
// Optimization: tell browser this element is independent for rendering
|
|
2444
|
+
contain: "paint layout",
|
|
2445
|
+
// Only apply center-padding for infinite carousels
|
|
2446
|
+
// Finite carousels should start/end at the edges
|
|
2447
|
+
...infinite ? {
|
|
2448
|
+
paddingLeft: `calc(50% - ${layout.cardWidth / 2}px)`,
|
|
2449
|
+
paddingRight: `calc(50% - ${layout.cardWidth / 2}px)`,
|
|
2450
|
+
scrollPaddingLeft: `calc(50% - ${layout.cardWidth / 2}px)`,
|
|
2451
|
+
scrollPaddingRight: `calc(50% - ${layout.cardWidth / 2}px)`
|
|
2452
|
+
} : {
|
|
2453
|
+
paddingLeft: "16px",
|
|
2454
|
+
paddingRight: "16px",
|
|
2455
|
+
scrollPaddingLeft: "16px",
|
|
2456
|
+
scrollPaddingRight: "16px"
|
|
2457
|
+
},
|
|
2458
|
+
minHeight: 0,
|
|
2459
|
+
opacity: isReady || isInstant ? 1 : 0
|
|
2460
|
+
},
|
|
2461
|
+
children: (0, import_react10.useMemo)(() => allItems.map((item, index) => {
|
|
2462
|
+
let type = "item";
|
|
2463
|
+
let realIndex = 0;
|
|
2464
|
+
if (infinite) {
|
|
2465
|
+
if (index < bufferBeforeCount) {
|
|
2466
|
+
type = "clone-before";
|
|
2467
|
+
realIndex = index % items.length;
|
|
2468
|
+
} else if (index >= bufferBeforeCount + items.length) {
|
|
2469
|
+
type = "clone-after";
|
|
2470
|
+
realIndex = (index - bufferBeforeCount - items.length) % items.length;
|
|
2471
|
+
} else {
|
|
2472
|
+
type = "original";
|
|
2473
|
+
realIndex = index - bufferBeforeCount;
|
|
2474
|
+
}
|
|
2475
|
+
} else {
|
|
2476
|
+
realIndex = index;
|
|
2477
|
+
}
|
|
2478
|
+
const key = `${type}-${getItemKey(item, realIndex)}-${index}`;
|
|
2479
|
+
const snapAlignment = infinite ? "snap-center" : "snap-start";
|
|
2480
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
2481
|
+
"div",
|
|
2482
|
+
{
|
|
2483
|
+
className: `carousel-item flex-shrink-0 ${itemClassName} cursor-pointer ${snapAlignment} snap-stop-always`,
|
|
2484
|
+
style: {
|
|
2485
|
+
width: widthCssValue,
|
|
2486
|
+
WebkitFontSmoothing: "subpixel-antialiased",
|
|
2487
|
+
WebkitTapHighlightColor: "transparent",
|
|
2488
|
+
scrollSnapStop: "always",
|
|
2489
|
+
contain: "layout paint"
|
|
2490
|
+
},
|
|
2491
|
+
children: renderItem(item, realIndex, { scrollToItem: () => scrollToThisItem(index) })
|
|
2492
|
+
},
|
|
2493
|
+
key
|
|
2494
|
+
);
|
|
2495
|
+
}), [allItems, infinite, bufferBeforeCount, items.length, getItemKey, renderItem, widthCssValue, itemClassName, scrollToThisItem])
|
|
2496
|
+
}
|
|
2497
|
+
),
|
|
2498
|
+
/* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CarouselArrow, { direction: "right", onClick: () => handleArrowClick("right"), className: "next" })
|
|
2499
|
+
]
|
|
2500
|
+
}
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
var Carousel = (0, import_react10.memo)(BaseCarouselInner);
|
|
2504
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2505
|
+
0 && (module.exports = {
|
|
2506
|
+
Carousel,
|
|
2507
|
+
CarouselArrow,
|
|
2508
|
+
CarouselLoggerInstance,
|
|
2509
|
+
DEBUG_CONFIG,
|
|
2510
|
+
FEATURE_FLAGS,
|
|
2511
|
+
LAYOUT_CONFIG,
|
|
2512
|
+
TIMING_CONFIG,
|
|
2513
|
+
VISUAL_CONFIG,
|
|
2514
|
+
carouselLogger,
|
|
2515
|
+
clearLoadingStateCache,
|
|
2516
|
+
createLogger,
|
|
2517
|
+
measureLayoutFromElement,
|
|
2518
|
+
reduce,
|
|
2519
|
+
useCarouselCoordinator,
|
|
2520
|
+
useCarouselLayout,
|
|
2521
|
+
useCarouselNavigation,
|
|
2522
|
+
useCarouselPersistence,
|
|
2523
|
+
useCarouselTeleport,
|
|
2524
|
+
useCarouselVisuals,
|
|
2525
|
+
useDraggableScroll,
|
|
2526
|
+
useLoadingState,
|
|
2527
|
+
useScrollCompletion
|
|
2528
|
+
});
|
|
2529
|
+
//# sourceMappingURL=index.cjs.map
|