@snowcone-app/ui 0.1.43 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +18 -4
- package/dist/index.cjs +5 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/package.json +9 -5
- package/src/components/CanvasIsolationBoundary.tsx +202 -0
- package/src/components/LoadingOverlayPrism.tsx +251 -0
- package/src/composed/AddToCart.tsx +229 -0
- package/src/composed/ArtAlignment.tsx +703 -0
- package/src/composed/ArtSelector.tsx +290 -0
- package/src/composed/ArtworkCustomizer.tsx +212 -0
- package/src/composed/CanvasEditor.tsx +79 -0
- package/src/composed/ColorPicker.tsx +111 -0
- package/src/composed/CurrentSelectionDisplay.tsx +86 -0
- package/src/composed/HeroProductImage.tsx +1079 -0
- package/src/composed/Lightbox.index.ts +2 -0
- package/src/composed/Lightbox.tsx +230 -0
- package/src/composed/PlacementClipShapeSelector.tsx +88 -0
- package/src/composed/PlacementTabs.tsx +179 -0
- package/src/composed/ProductCard.tsx +298 -0
- package/src/composed/ProductGallery.tsx +54 -0
- package/src/composed/ProductImage.tsx +129 -0
- package/src/composed/ProductList.tsx +147 -0
- package/src/composed/ProductOptions.tsx +305 -0
- package/src/composed/RealtimeMockup.tsx +121 -0
- package/src/composed/TileCount.tsx +348 -0
- package/src/composed/carousels/HeroCarousel.tsx +240 -0
- package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
- package/src/composed/carousels/index.ts +11 -0
- package/src/composed/carousels/types.ts +58 -0
- package/src/composed/grids/MasonryGrid.tsx +238 -0
- package/src/composed/grids/index.ts +9 -0
- package/src/composed/search/CurrentRefinements.tsx +80 -0
- package/src/composed/search/Filters.tsx +49 -0
- package/src/composed/search/FiltersButton.tsx +57 -0
- package/src/composed/search/FiltersDrawer.tsx +375 -0
- package/src/composed/search/ProductGrid.tsx +118 -0
- package/src/composed/search/ProductHit.tsx +56 -0
- package/src/composed/search/SearchBox.tsx +109 -0
- package/src/composed/search/SearchProvider.tsx +136 -0
- package/src/composed/search/facetConfig.ts +16 -0
- package/src/composed/search/index.ts +22 -0
- package/src/composed/search/meilisearchAdapter.ts +20 -0
- package/src/composed/search/types.ts +22 -0
- package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
- package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
- package/src/composed/zoom/ZoomOverlay.tsx +194 -0
- package/src/composed/zoom/index.ts +12 -0
- package/src/composed/zoom/types.ts +12 -0
- package/src/design-system/ColorPalette.tsx +126 -0
- package/src/design-system/ColorSwatch.tsx +49 -0
- package/src/design-system/DesignSystemPage.tsx +130 -0
- package/src/design-system/ThemeSwitcher.tsx +181 -0
- package/src/design-system/TypographyScale.tsx +106 -0
- package/src/design-system/index.ts +5 -0
- package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
- package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
- package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
- package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
- package/src/hooks/useBrand.ts +41 -0
- package/src/hooks/useCanvasContext.ts +127 -0
- package/src/hooks/useDeviceDetection.ts +64 -0
- package/src/hooks/useFocusTrap.ts +70 -0
- package/src/hooks/useImagePreloader.ts +268 -0
- package/src/hooks/useImageTransition.ts +608 -0
- package/src/hooks/usePlacementsProcessor.ts +74 -0
- package/src/hooks/useProductGallery.ts +193 -0
- package/src/hooks/useProductPage.ts +467 -0
- package/src/hooks/useRenderGuard.ts +96 -0
- package/src/hooks/useScrollDirection.ts +196 -0
- package/src/hooks/viewport/index.ts +25 -0
- package/src/hooks/viewport/useContainerWidth.ts +59 -0
- package/src/hooks/viewport/useMediaQuery.ts +52 -0
- package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
- package/src/hooks/viewport/useViewportDimensions.ts +135 -0
- package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
- package/src/hooks/visibility/index.ts +15 -0
- package/src/hooks/visibility/observerPool.ts +150 -0
- package/src/index.ts +240 -0
- package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
- package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
- package/src/layouts/hero-zoom/index.ts +30 -0
- package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
- package/src/layouts/hero-zoom/types.ts +113 -0
- package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
- package/src/layouts/index.ts +9 -0
- package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
- package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
- package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
- package/src/layouts/pdp/PDPLayout.tsx +246 -0
- package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
- package/src/layouts/pdp/index.ts +40 -0
- package/src/lib/env.ts +15 -0
- package/src/lib/locale.ts +167 -0
- package/src/lib/router.tsx +46 -0
- package/src/lib/utils.ts +6 -0
- package/src/lightbox/README.md +77 -0
- package/src/next/index.tsx +26 -0
- package/src/patterns/MockupPriorityProvider.tsx +1014 -0
- package/src/patterns/Product.tsx +850 -0
- package/src/patterns/ProductPageProvider.tsx +224 -0
- package/src/patterns/RealtimeProvider.tsx +1162 -0
- package/src/patterns/ShopProvider.tsx +603 -0
- package/src/personalization/PersonalizationBridge.tsx +235 -0
- package/src/personalization/PersonalizationContext.ts +29 -0
- package/src/personalization/PersonalizationInputs.tsx +110 -0
- package/src/personalization/PersonalizationProvider.tsx +407 -0
- package/src/personalization/canvas-stub.d.ts +22 -0
- package/src/personalization/index.ts +43 -0
- package/src/personalization/types.ts +48 -0
- package/src/personalization/usePersonalization.ts +32 -0
- package/src/personalization/usePersonalizationShimmer.ts +159 -0
- package/src/personalization/utils.ts +59 -0
- package/src/primitives/BrandLogo.tsx +65 -0
- package/src/primitives/BrandName.tsx +51 -0
- package/src/primitives/Button.tsx +123 -0
- package/src/primitives/ColorSwatch.tsx +221 -0
- package/src/primitives/DragHintAnimation.tsx +190 -0
- package/src/primitives/EdgeSwipeGuards.tsx +60 -0
- package/src/primitives/FloatingActionGroup.tsx +176 -0
- package/src/primitives/ProductPrice.tsx +171 -0
- package/src/primitives/ProgressiveBlur.tsx +295 -0
- package/src/primitives/ThemeToggle.tsx +125 -0
- package/src/primitives/__tests__/story-coverage.test.ts +98 -0
- package/src/primitives/accordion.tsx +280 -0
- package/src/primitives/badge.tsx +137 -0
- package/src/primitives/card.tsx +61 -0
- package/src/primitives/checkbox.tsx +56 -0
- package/src/primitives/collapsible.tsx +51 -0
- package/src/primitives/drawer.tsx +828 -0
- package/src/primitives/dropdown-menu.tsx +197 -0
- package/src/primitives/fieldset.tsx +73 -0
- package/src/primitives/index.ts +138 -0
- package/src/primitives/input.tsx +91 -0
- package/src/primitives/kbd.tsx +130 -0
- package/src/primitives/label.tsx +20 -0
- package/src/primitives/link.tsx +182 -0
- package/src/primitives/popover.tsx +80 -0
- package/src/primitives/radio-group.tsx +79 -0
- package/src/primitives/scroll-fade.tsx +159 -0
- package/src/primitives/select.tsx +170 -0
- package/src/primitives/separator.tsx +25 -0
- package/src/primitives/slider.tsx +221 -0
- package/src/primitives/spinner.tsx +72 -0
- package/src/primitives/stories/Accordion.stories.tsx +121 -0
- package/src/primitives/stories/Badge.stories.tsx +221 -0
- package/src/primitives/stories/Button.stories.tsx +185 -0
- package/src/primitives/stories/Card.stories.tsx +171 -0
- package/src/primitives/stories/Checkbox.stories.tsx +214 -0
- package/src/primitives/stories/Collapsible.stories.tsx +230 -0
- package/src/primitives/stories/Drawer.stories.tsx +378 -0
- package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
- package/src/primitives/stories/Fieldset.stories.tsx +212 -0
- package/src/primitives/stories/Input.stories.tsx +172 -0
- package/src/primitives/stories/Kbd.stories.tsx +183 -0
- package/src/primitives/stories/Label.stories.tsx +98 -0
- package/src/primitives/stories/Link.stories.tsx +260 -0
- package/src/primitives/stories/Popover.stories.tsx +178 -0
- package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
- package/src/primitives/stories/Select.stories.tsx +222 -0
- package/src/primitives/stories/Separator.stories.tsx +134 -0
- package/src/primitives/stories/Slider.stories.tsx +203 -0
- package/src/primitives/stories/Spinner.stories.tsx +142 -0
- package/src/primitives/stories/Surface.stories.tsx +257 -0
- package/src/primitives/stories/Switch.stories.tsx +131 -0
- package/src/primitives/stories/Tabs.stories.tsx +275 -0
- package/src/primitives/stories/TextField.stories.tsx +139 -0
- package/src/primitives/stories/Textarea.stories.tsx +148 -0
- package/src/primitives/stories/Tooltip.stories.tsx +119 -0
- package/src/primitives/surface.tsx +86 -0
- package/src/primitives/switch.tsx +35 -0
- package/src/primitives/tabs.tsx +206 -0
- package/src/primitives/text-field.tsx +84 -0
- package/src/primitives/textarea.tsx +50 -0
- package/src/primitives/tooltip.tsx +58 -0
- package/src/services/CanvasExportService.ts +518 -0
- package/src/styles/base.css +380 -0
- package/src/styles/defaults.css +280 -0
- package/src/styles/globals.css +1242 -0
- package/src/styles/index.css +17 -0
- package/src/styles/ne-themes.css +4740 -0
- package/src/styles/tailwind.css +11 -0
- package/src/styles/tokens.css +117 -0
- package/src/styles/utilities.css +188 -0
- package/src/themes/apply-theme.ts +449 -0
- package/src/themes/getThemeStyles.ts +454 -0
- package/src/themes/index.ts +48 -0
- package/src/themes/oklch-theme.ts +283 -0
- package/src/themes/presets.ts +989 -0
- package/src/themes/types.ts +386 -0
- package/src/themes/useTheme.tsx +450 -0
- package/src/utils/dev-warnings.ts +161 -0
- package/src/utils/devWarnings.ts +153 -0
- package/dist/styles.css +0 -1
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MockupPriorityProvider - Priority-based mockup rendering coordinator
|
|
5
|
+
*
|
|
6
|
+
* Manages a 3-level priority system for mockup rendering:
|
|
7
|
+
* - Level 1: Single most visible mockup (highest priority, pauses others)
|
|
8
|
+
* - Level 2: Other visible/partially visible mockups
|
|
9
|
+
* - Level 3: Off-screen mockups (max 5 prefetch, sorted by likelihood)
|
|
10
|
+
*
|
|
11
|
+
* Architecture:
|
|
12
|
+
* - Uses refs for visibility state to avoid re-renders during scroll
|
|
13
|
+
* - Only triggers state update when Level 1 mockup changes
|
|
14
|
+
* - Provides subscription system for components that need reactive updates
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```tsx
|
|
18
|
+
* <RealtimeProvider>
|
|
19
|
+
* <MockupPriorityProvider>
|
|
20
|
+
* <HeroProductImage mockupId="front" />
|
|
21
|
+
* <HeroProductImage mockupId="back" />
|
|
22
|
+
* </MockupPriorityProvider>
|
|
23
|
+
* </RealtimeProvider>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import React, {
|
|
28
|
+
createContext,
|
|
29
|
+
useContext,
|
|
30
|
+
useCallback,
|
|
31
|
+
useRef,
|
|
32
|
+
useMemo,
|
|
33
|
+
useState,
|
|
34
|
+
useEffect,
|
|
35
|
+
} from 'react';
|
|
36
|
+
import {
|
|
37
|
+
useScrollDirection,
|
|
38
|
+
createScrollDirectionTracker,
|
|
39
|
+
type ScrollDirection,
|
|
40
|
+
} from '../hooks/useScrollDirection';
|
|
41
|
+
import { useRealtimeOptional } from './RealtimeProvider';
|
|
42
|
+
|
|
43
|
+
// Constants
|
|
44
|
+
const MAX_LEVEL3_PREFETCH = 5;
|
|
45
|
+
const PRIORITY_RECALC_DEBOUNCE_MS = 50;
|
|
46
|
+
const PRELOAD_MARGIN_PX = 750; // Load images 750px before they enter viewport
|
|
47
|
+
|
|
48
|
+
// Types
|
|
49
|
+
export type PriorityLevel = 1 | 2 | 3;
|
|
50
|
+
|
|
51
|
+
export interface VisibilityInfo {
|
|
52
|
+
mockupId: string;
|
|
53
|
+
placement: string;
|
|
54
|
+
intersectionRatio: number;
|
|
55
|
+
visibleArea: number;
|
|
56
|
+
boundingRect: DOMRectReadOnly | null;
|
|
57
|
+
distanceFromCenter: number;
|
|
58
|
+
isAboveViewport: boolean;
|
|
59
|
+
lastUpdated: number;
|
|
60
|
+
/** True if element is within PRELOAD_MARGIN_PX of viewport (for preloading before visible) */
|
|
61
|
+
isInPreloadZone: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MockupPriorityContextValue {
|
|
65
|
+
// Registration - pass element ref for scroll-based visibility calculation
|
|
66
|
+
registerMockup: (
|
|
67
|
+
mockupId: string,
|
|
68
|
+
placement: string,
|
|
69
|
+
initialPriority?: PriorityLevel,
|
|
70
|
+
elementRef?: HTMLElement | null
|
|
71
|
+
) => void;
|
|
72
|
+
unregisterMockup: (mockupId: string) => void;
|
|
73
|
+
|
|
74
|
+
// Update element ref (if element mounts after registration)
|
|
75
|
+
updateElementRef: (mockupId: string, element: HTMLElement | null) => void;
|
|
76
|
+
|
|
77
|
+
// Legacy: kept for compatibility but no longer needed with scroll-based approach
|
|
78
|
+
reportVisibility: (
|
|
79
|
+
mockupId: string,
|
|
80
|
+
ratio: number,
|
|
81
|
+
boundingRect: DOMRectReadOnly
|
|
82
|
+
) => void;
|
|
83
|
+
|
|
84
|
+
// Mobile carousel integration
|
|
85
|
+
reportMobileCarouselIndex: (currentIndex: number, mockupIds: string[]) => void;
|
|
86
|
+
clearMobileCarouselMode: () => void;
|
|
87
|
+
|
|
88
|
+
// Priority queries (synchronous, reads from refs)
|
|
89
|
+
getPriorityLevel: (mockupId: string) => PriorityLevel;
|
|
90
|
+
/**
|
|
91
|
+
* Get numeric priority for Shop queue (100 for L1, 50 for L2, 10 for L3).
|
|
92
|
+
* Used by ProductImage to integrate with Shop's priority queue in non-realtime mode.
|
|
93
|
+
*/
|
|
94
|
+
getQueuePriority: (mockupId: string) => number;
|
|
95
|
+
getLevel1Mockup: () => string | null;
|
|
96
|
+
getLevel2Mockups: () => string[];
|
|
97
|
+
getLevel3Queue: () => string[];
|
|
98
|
+
getVisibility: (mockupId: string) => VisibilityInfo | null;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get all mockupIds for a specific priority level.
|
|
102
|
+
* Returns array of mockupIds that should be rendered at that level.
|
|
103
|
+
*/
|
|
104
|
+
getMockupIdsForLevel: (level: PriorityLevel) => string[];
|
|
105
|
+
|
|
106
|
+
// Pause state (for RealtimeProvider)
|
|
107
|
+
shouldPauseLevel: (level: 2 | 3) => boolean;
|
|
108
|
+
isLevel1Active: () => boolean;
|
|
109
|
+
|
|
110
|
+
// Subscriptions for reactive updates (optional)
|
|
111
|
+
subscribePriorityChange: (
|
|
112
|
+
mockupId: string,
|
|
113
|
+
callback: (level: PriorityLevel) => void
|
|
114
|
+
) => () => void;
|
|
115
|
+
|
|
116
|
+
// Subscribe to Level 1 changes (for RealtimeProvider)
|
|
117
|
+
subscribeLevel1Change: (
|
|
118
|
+
callback: (mockupId: string | null) => void
|
|
119
|
+
) => () => void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const MockupPriorityContext = createContext<MockupPriorityContextValue | undefined>(
|
|
123
|
+
undefined
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
export interface MockupPriorityProviderProps {
|
|
127
|
+
children: React.ReactNode;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function MockupPriorityProvider({ children }: MockupPriorityProviderProps) {
|
|
131
|
+
// Log on mount
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
return () => {
|
|
134
|
+
};
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
// REF-BASED STATE (no re-renders on visibility changes)
|
|
138
|
+
const visibilityMapRef = useRef<Map<string, VisibilityInfo>>(new Map());
|
|
139
|
+
const prioritiesRef = useRef<Map<string, PriorityLevel>>(new Map());
|
|
140
|
+
const level1MockupRef = useRef<string | null>(null);
|
|
141
|
+
const level2MockupsRef = useRef<string[]>([]);
|
|
142
|
+
const level3QueueRef = useRef<string[]>([]);
|
|
143
|
+
|
|
144
|
+
// Mobile carousel state - when set, uses index-based priority instead of visibility
|
|
145
|
+
const mobileCarouselRef = useRef<{
|
|
146
|
+
currentIndex: number;
|
|
147
|
+
mockupIds: string[];
|
|
148
|
+
} | null>(null);
|
|
149
|
+
|
|
150
|
+
// Track registration order for index-based L2 assignment (desktop scroll mode)
|
|
151
|
+
const registrationOrderRef = useRef<string[]>([]);
|
|
152
|
+
|
|
153
|
+
// Store element refs for scroll-based visibility calculation
|
|
154
|
+
const elementRefsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
155
|
+
|
|
156
|
+
// Subscriptions for reactive updates
|
|
157
|
+
const prioritySubscribersRef = useRef<Map<string, Set<(level: PriorityLevel) => void>>>(
|
|
158
|
+
new Map()
|
|
159
|
+
);
|
|
160
|
+
const level1SubscribersRef = useRef<Set<(mockupId: string | null) => void>>(
|
|
161
|
+
new Set()
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Scroll direction for Level 3 ordering
|
|
165
|
+
const scrollState = useScrollDirection();
|
|
166
|
+
const scrollDirectionRef = useRef<ScrollDirection>('idle');
|
|
167
|
+
scrollDirectionRef.current = scrollState.direction;
|
|
168
|
+
|
|
169
|
+
// Debounce timer for priority recalculation
|
|
170
|
+
const recalcTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
171
|
+
// Track pending rAF for first-visible recalculation (to avoid multiple rAF calls)
|
|
172
|
+
const pendingRafRef = useRef<number | null>(null);
|
|
173
|
+
|
|
174
|
+
// STATE: Only track Level 1 for minimal re-renders (used by RealtimeProvider)
|
|
175
|
+
const [level1MockupId, setLevel1MockupId] = useState<string | null>(null);
|
|
176
|
+
|
|
177
|
+
// Access RealtimeProvider for priority-based mockup requests
|
|
178
|
+
const realtimeContext = useRealtimeOptional();
|
|
179
|
+
|
|
180
|
+
// Priority rendering state
|
|
181
|
+
const currentRenderingLevelRef = useRef<PriorityLevel | null>(null);
|
|
182
|
+
const pendingMockupsRef = useRef<Set<string>>(new Set());
|
|
183
|
+
const renderedMockupsRef = useRef<Set<string>>(new Set());
|
|
184
|
+
|
|
185
|
+
// Track pending blob timestamps to prevent premature level advancement during drag
|
|
186
|
+
const lastPendingBlobTimeRef = useRef<number>(0);
|
|
187
|
+
const DRAG_STOP_DEBOUNCE_MS = 250; // Time to wait after last blob before advancing to Level 2+
|
|
188
|
+
const BLOB_SEND_DELAY_MS = 100; // Short delay to let blob be sent before requesting mockups
|
|
189
|
+
|
|
190
|
+
// Timer for delayed mockup request after drag stops
|
|
191
|
+
const pendingBlobRequestTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Request mockups for a specific priority level.
|
|
195
|
+
* Uses updateMockupIds to tell server which mockups to render.
|
|
196
|
+
*/
|
|
197
|
+
const requestMockupsForLevel = useCallback((level: PriorityLevel) => {
|
|
198
|
+
|
|
199
|
+
if (!realtimeContext?.updateMockupIds) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let mockupIds: string[];
|
|
204
|
+
switch (level) {
|
|
205
|
+
case 1:
|
|
206
|
+
mockupIds = level1MockupRef.current ? [level1MockupRef.current] : [];
|
|
207
|
+
break;
|
|
208
|
+
case 2:
|
|
209
|
+
mockupIds = [...level2MockupsRef.current];
|
|
210
|
+
break;
|
|
211
|
+
case 3:
|
|
212
|
+
mockupIds = [...level3QueueRef.current];
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
mockupIds = [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (mockupIds.length === 0) {
|
|
219
|
+
// No mockups at this level, advance to next
|
|
220
|
+
if (level < 3) {
|
|
221
|
+
requestMockupsForLevel((level + 1) as PriorityLevel);
|
|
222
|
+
}
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
currentRenderingLevelRef.current = level;
|
|
227
|
+
pendingMockupsRef.current = new Set(mockupIds);
|
|
228
|
+
realtimeContext.updateMockupIds(mockupIds);
|
|
229
|
+
}, [realtimeContext]);
|
|
230
|
+
|
|
231
|
+
// Timer ref for delayed level advancement
|
|
232
|
+
const levelAdvanceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Handle mockup result - check if current level is complete
|
|
236
|
+
*/
|
|
237
|
+
const handleMockupResult = useCallback((mockupId: string) => {
|
|
238
|
+
// Mark as rendered
|
|
239
|
+
renderedMockupsRef.current.add(mockupId);
|
|
240
|
+
pendingMockupsRef.current.delete(mockupId);
|
|
241
|
+
|
|
242
|
+
const currentLevel = currentRenderingLevelRef.current;
|
|
243
|
+
if (currentLevel === null) return;
|
|
244
|
+
|
|
245
|
+
// Check if all pending mockups for current level are done
|
|
246
|
+
if (pendingMockupsRef.current.size === 0) {
|
|
247
|
+
const timeSinceLastPendingBlob = Date.now() - lastPendingBlobTimeRef.current;
|
|
248
|
+
const isStillDragging = timeSinceLastPendingBlob < DRAG_STOP_DEBOUNCE_MS;
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
// Level 1 complete - decide whether to advance to Level 2+
|
|
252
|
+
if (currentLevel === 1 && isStillDragging) {
|
|
253
|
+
// Still dragging - stay on Level 1, don't advance to Level 2
|
|
254
|
+
// The next pending blob will re-request Level 1
|
|
255
|
+
currentRenderingLevelRef.current = null; // Ready for next Level 1 request
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (isStillDragging) {
|
|
260
|
+
// For Level 2/3 during drag, delay advancement
|
|
261
|
+
|
|
262
|
+
if (levelAdvanceTimeoutRef.current) {
|
|
263
|
+
clearTimeout(levelAdvanceTimeoutRef.current);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
levelAdvanceTimeoutRef.current = setTimeout(() => {
|
|
267
|
+
const newTimeSince = Date.now() - lastPendingBlobTimeRef.current;
|
|
268
|
+
if (newTimeSince >= DRAG_STOP_DEBOUNCE_MS && pendingMockupsRef.current.size === 0) {
|
|
269
|
+
if (currentLevel < 3) {
|
|
270
|
+
requestMockupsForLevel((currentLevel + 1) as PriorityLevel);
|
|
271
|
+
} else {
|
|
272
|
+
currentRenderingLevelRef.current = null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
levelAdvanceTimeoutRef.current = null;
|
|
276
|
+
}, DRAG_STOP_DEBOUNCE_MS - timeSinceLastPendingBlob + 10);
|
|
277
|
+
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Not dragging - advance immediately
|
|
282
|
+
if (currentLevel < 3) {
|
|
283
|
+
requestMockupsForLevel((currentLevel + 1) as PriorityLevel);
|
|
284
|
+
} else {
|
|
285
|
+
currentRenderingLevelRef.current = null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}, [requestMockupsForLevel]);
|
|
289
|
+
|
|
290
|
+
// Subscribe to mockup results for level advancement
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (!realtimeContext?.subscribeMockupResults) return;
|
|
293
|
+
|
|
294
|
+
const unsubscribe = realtimeContext.subscribeMockupResults((results) => {
|
|
295
|
+
results.forEach((result) => {
|
|
296
|
+
handleMockupResult(result.mockupId);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return unsubscribe;
|
|
301
|
+
}, [realtimeContext, handleMockupResult]);
|
|
302
|
+
|
|
303
|
+
// Log when realtimeContext changes and trigger priority flow when isConfigured becomes true
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
|
|
306
|
+
// When isConfigured becomes true and we have a Level 1 mockup, start priority flow
|
|
307
|
+
if (realtimeContext?.isConfigured && level1MockupRef.current) {
|
|
308
|
+
renderedMockupsRef.current.clear();
|
|
309
|
+
requestMockupsForLevel(1);
|
|
310
|
+
}
|
|
311
|
+
}, [realtimeContext, requestMockupsForLevel]);
|
|
312
|
+
|
|
313
|
+
// When Level 1 changes, restart the priority flow (only if realtime is configured)
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
|
|
316
|
+
if (level1MockupId === null) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Wait until realtime is configured before starting priority flow
|
|
321
|
+
if (!realtimeContext?.isConfigured) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Reset rendered tracking for new priority calculation
|
|
326
|
+
renderedMockupsRef.current.clear();
|
|
327
|
+
|
|
328
|
+
// Start requesting from Level 1
|
|
329
|
+
requestMockupsForLevel(1);
|
|
330
|
+
}, [level1MockupId, realtimeContext?.isConfigured, requestMockupsForLevel]);
|
|
331
|
+
|
|
332
|
+
// Subscribe to pending blob notifications to track drag timing (for L2+ delay)
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (!realtimeContext?.subscribePendingBlob) return;
|
|
335
|
+
|
|
336
|
+
const handlePendingBlob = (placement: string) => {
|
|
337
|
+
|
|
338
|
+
// Track the time of this pending blob event for drag detection
|
|
339
|
+
lastPendingBlobTimeRef.current = Date.now();
|
|
340
|
+
|
|
341
|
+
// Cancel any pending level advancement - user is still making changes
|
|
342
|
+
if (levelAdvanceTimeoutRef.current) {
|
|
343
|
+
clearTimeout(levelAdvanceTimeoutRef.current);
|
|
344
|
+
levelAdvanceTimeoutRef.current = null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// NOTE: Do NOT reset currentRenderingLevelRef, pendingMockupsRef, or renderedMockupsRef here!
|
|
348
|
+
// This handler fires repeatedly during drag, and resetting state here would cause
|
|
349
|
+
// in-flight L1 requests to be ignored when their results arrive.
|
|
350
|
+
// The state reset happens in handleBlobReceived when we actually start a new request.
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const unsubscribe = realtimeContext.subscribePendingBlob(handlePendingBlob);
|
|
354
|
+
return unsubscribe;
|
|
355
|
+
}, [realtimeContext]);
|
|
356
|
+
|
|
357
|
+
// Subscribe to blob sent notifications to trigger mockup requests
|
|
358
|
+
// This fires when the blob is actually sent (after throttle completes)
|
|
359
|
+
// More reliable than blob_received which only fires for NEW placements
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (!realtimeContext?.subscribeBlobSent) return;
|
|
362
|
+
|
|
363
|
+
const handleBlobSent = (placement: string) => {
|
|
364
|
+
|
|
365
|
+
// Cancel any pending mockup request timeout (shouldn't happen, but just in case)
|
|
366
|
+
if (pendingBlobRequestTimeoutRef.current) {
|
|
367
|
+
clearTimeout(pendingBlobRequestTimeoutRef.current);
|
|
368
|
+
pendingBlobRequestTimeoutRef.current = null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Reset state for new priority flow - we're starting fresh with the new blob
|
|
372
|
+
currentRenderingLevelRef.current = null;
|
|
373
|
+
pendingMockupsRef.current.clear();
|
|
374
|
+
renderedMockupsRef.current.clear();
|
|
375
|
+
|
|
376
|
+
// NOTE: Do NOT update lastPendingBlobTimeRef here!
|
|
377
|
+
// That timestamp tracks when the user is actively dragging (pendingBlob events).
|
|
378
|
+
// blob_sent happens after throttle delay, and we use the
|
|
379
|
+
// time since last pendingBlob to detect if user stopped dragging.
|
|
380
|
+
// If we reset it here, L1 might complete within 250ms and we'd think
|
|
381
|
+
// we're still dragging, preventing advancement to L2.
|
|
382
|
+
|
|
383
|
+
// Request Level 1 mockups immediately - blob is now on its way to server
|
|
384
|
+
if (level1MockupRef.current) {
|
|
385
|
+
requestMockupsForLevel(1);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const unsubscribe = realtimeContext.subscribeBlobSent(handleBlobSent);
|
|
390
|
+
return unsubscribe;
|
|
391
|
+
}, [realtimeContext, requestMockupsForLevel]);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Recalculate priorities based on current state.
|
|
395
|
+
*
|
|
396
|
+
* MOBILE CAROUSEL MODE (simple index-based):
|
|
397
|
+
* - Level 1: Current carousel item
|
|
398
|
+
* - Level 2: Adjacent items (±1)
|
|
399
|
+
* - Level 3: All other items
|
|
400
|
+
*
|
|
401
|
+
* DESKTOP MODE (visibility-based):
|
|
402
|
+
* - Level 1: Mockup with largest visible pixel area
|
|
403
|
+
* - Level 2: Other visible mockups
|
|
404
|
+
* - Level 3: Off-screen mockups
|
|
405
|
+
*/
|
|
406
|
+
const recalculatePriorities = useCallback(() => {
|
|
407
|
+
const mobileState = mobileCarouselRef.current;
|
|
408
|
+
|
|
409
|
+
// ===== MOBILE CAROUSEL MODE =====
|
|
410
|
+
if (mobileState && mobileState.mockupIds.length > 0) {
|
|
411
|
+
const { currentIndex, mockupIds } = mobileState;
|
|
412
|
+
|
|
413
|
+
let newLevel1: string | null = null;
|
|
414
|
+
const newLevel2Set = new Set<string>();
|
|
415
|
+
const newLevel3Set = new Set<string>();
|
|
416
|
+
const changedMockups: Array<{ mockupId: string; newLevel: PriorityLevel }> = [];
|
|
417
|
+
|
|
418
|
+
// Calculate new priorities based on carousel index
|
|
419
|
+
mockupIds.forEach((id, index) => {
|
|
420
|
+
const oldLevel = prioritiesRef.current.get(id);
|
|
421
|
+
let newLevel: PriorityLevel;
|
|
422
|
+
|
|
423
|
+
if (index === currentIndex) {
|
|
424
|
+
newLevel = 1;
|
|
425
|
+
} else if (Math.abs(index - currentIndex) === 1) {
|
|
426
|
+
newLevel = 2;
|
|
427
|
+
} else {
|
|
428
|
+
newLevel = 3;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Always set the priority (carousel index is authoritative)
|
|
432
|
+
prioritiesRef.current.set(id, newLevel);
|
|
433
|
+
|
|
434
|
+
if (newLevel === 1) {
|
|
435
|
+
newLevel1 = id;
|
|
436
|
+
} else if (newLevel === 2) {
|
|
437
|
+
newLevel2Set.add(id);
|
|
438
|
+
} else {
|
|
439
|
+
newLevel3Set.add(id);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Track if priority actually changed (for subscriber notification)
|
|
443
|
+
if (oldLevel !== newLevel) {
|
|
444
|
+
changedMockups.push({ mockupId: id, newLevel });
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
level2MockupsRef.current = [...newLevel2Set];
|
|
449
|
+
level3QueueRef.current = [...newLevel3Set].slice(0, MAX_LEVEL3_PREFETCH);
|
|
450
|
+
|
|
451
|
+
// Update Level 1 if changed
|
|
452
|
+
if (newLevel1 !== level1MockupRef.current) {
|
|
453
|
+
level1MockupRef.current = newLevel1;
|
|
454
|
+
setLevel1MockupId(newLevel1);
|
|
455
|
+
level1SubscribersRef.current.forEach((cb) => cb(newLevel1));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Only notify subscribers for mockups whose priority actually changed
|
|
459
|
+
changedMockups.forEach(({ mockupId, newLevel }) => {
|
|
460
|
+
const subscribers = prioritySubscribersRef.current.get(mockupId);
|
|
461
|
+
subscribers?.forEach((cb) => cb(newLevel));
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ===== DESKTOP MODE (visibility-based) =====
|
|
468
|
+
const visibilities = [...visibilityMapRef.current.entries()];
|
|
469
|
+
|
|
470
|
+
if (visibilities.length === 0) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Find the mockup with the largest visible area
|
|
475
|
+
let maxVisibleArea = 0;
|
|
476
|
+
let level1Candidate: string | null = null;
|
|
477
|
+
|
|
478
|
+
visibilities.forEach(([mockupId, info]) => {
|
|
479
|
+
if (info.visibleArea > maxVisibleArea) {
|
|
480
|
+
maxVisibleArea = info.visibleArea;
|
|
481
|
+
level1Candidate = mockupId;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// Assign levels based on visibility and track changes
|
|
486
|
+
const newLevel2: string[] = [];
|
|
487
|
+
const newLevel3: string[] = [];
|
|
488
|
+
const changedMockups: Array<{ mockupId: string; newLevel: PriorityLevel }> = [];
|
|
489
|
+
|
|
490
|
+
visibilities.forEach(([mockupId, info]) => {
|
|
491
|
+
const oldLevel = prioritiesRef.current.get(mockupId);
|
|
492
|
+
let newLevel: PriorityLevel;
|
|
493
|
+
|
|
494
|
+
if (mockupId === level1Candidate) {
|
|
495
|
+
newLevel = 1;
|
|
496
|
+
} else if (info.visibleArea > 0) {
|
|
497
|
+
// Actually visible in viewport
|
|
498
|
+
newLevel = 2;
|
|
499
|
+
newLevel2.push(mockupId);
|
|
500
|
+
} else if (info.isInPreloadZone) {
|
|
501
|
+
// Within preload margin - promote to Level 2 to start loading before visible
|
|
502
|
+
newLevel = 2;
|
|
503
|
+
newLevel2.push(mockupId);
|
|
504
|
+
} else {
|
|
505
|
+
newLevel = 3;
|
|
506
|
+
newLevel3.push(mockupId);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
prioritiesRef.current.set(mockupId, newLevel);
|
|
510
|
+
|
|
511
|
+
// Track if priority changed
|
|
512
|
+
if (oldLevel !== newLevel) {
|
|
513
|
+
changedMockups.push({ mockupId, newLevel });
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
level2MockupsRef.current = newLevel2;
|
|
518
|
+
level3QueueRef.current = newLevel3.slice(0, MAX_LEVEL3_PREFETCH);
|
|
519
|
+
|
|
520
|
+
// Update Level 1 if changed
|
|
521
|
+
if (level1Candidate !== level1MockupRef.current && (level1Candidate !== null || maxVisibleArea > 0)) {
|
|
522
|
+
level1MockupRef.current = level1Candidate;
|
|
523
|
+
setLevel1MockupId(level1Candidate);
|
|
524
|
+
level1SubscribersRef.current.forEach((cb) => cb(level1Candidate));
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Only notify subscribers for mockups whose priority actually changed
|
|
528
|
+
changedMockups.forEach(({ mockupId, newLevel }) => {
|
|
529
|
+
const subscribers = prioritySubscribersRef.current.get(mockupId);
|
|
530
|
+
subscribers?.forEach((cb) => cb(newLevel));
|
|
531
|
+
});
|
|
532
|
+
}, []);
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Debounced priority recalculation
|
|
536
|
+
*/
|
|
537
|
+
const scheduleRecalculation = useCallback(() => {
|
|
538
|
+
if (recalcTimeoutRef.current) {
|
|
539
|
+
clearTimeout(recalcTimeoutRef.current);
|
|
540
|
+
}
|
|
541
|
+
recalcTimeoutRef.current = setTimeout(() => {
|
|
542
|
+
recalculatePriorities();
|
|
543
|
+
recalcTimeoutRef.current = null;
|
|
544
|
+
}, PRIORITY_RECALC_DEBOUNCE_MS);
|
|
545
|
+
}, [recalculatePriorities]);
|
|
546
|
+
|
|
547
|
+
// Cleanup on unmount
|
|
548
|
+
useEffect(() => {
|
|
549
|
+
return () => {
|
|
550
|
+
if (recalcTimeoutRef.current) {
|
|
551
|
+
clearTimeout(recalcTimeoutRef.current);
|
|
552
|
+
}
|
|
553
|
+
if (levelAdvanceTimeoutRef.current) {
|
|
554
|
+
clearTimeout(levelAdvanceTimeoutRef.current);
|
|
555
|
+
}
|
|
556
|
+
if (pendingBlobRequestTimeoutRef.current) {
|
|
557
|
+
clearTimeout(pendingBlobRequestTimeoutRef.current);
|
|
558
|
+
}
|
|
559
|
+
if (pendingRafRef.current !== null) {
|
|
560
|
+
cancelAnimationFrame(pendingRafRef.current);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Update visibility data for all mockups from their element refs.
|
|
567
|
+
* Called on scroll to keep visibility data fresh.
|
|
568
|
+
*/
|
|
569
|
+
const updateVisibilityFromRefs = useCallback(() => {
|
|
570
|
+
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
571
|
+
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1200;
|
|
572
|
+
const viewportCenterY = viewportHeight / 2;
|
|
573
|
+
|
|
574
|
+
const elementCount = elementRefsRef.current.size;
|
|
575
|
+
if (elementCount === 0) {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
elementRefsRef.current.forEach((element, mockupId) => {
|
|
580
|
+
const existing = visibilityMapRef.current.get(mockupId);
|
|
581
|
+
if (!existing) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const rect = element.getBoundingClientRect();
|
|
586
|
+
|
|
587
|
+
// Skip hidden elements (display: none returns all zeros)
|
|
588
|
+
// This handles responsive layouts where only one version is visible
|
|
589
|
+
if (rect.height === 0 || rect.width === 0) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const elementCenterY = rect.top + rect.height / 2;
|
|
594
|
+
const distanceFromCenter = Math.abs(viewportCenterY - elementCenterY);
|
|
595
|
+
|
|
596
|
+
// Calculate visible area
|
|
597
|
+
const visibleTop = Math.max(0, rect.top);
|
|
598
|
+
const visibleBottom = Math.min(viewportHeight, rect.bottom);
|
|
599
|
+
const visibleLeft = Math.max(0, rect.left);
|
|
600
|
+
const visibleRight = Math.min(viewportWidth, rect.right);
|
|
601
|
+
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
|
602
|
+
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
603
|
+
const visibleArea = visibleWidth * visibleHeight;
|
|
604
|
+
|
|
605
|
+
// Calculate intersection ratio
|
|
606
|
+
const totalArea = rect.width * rect.height;
|
|
607
|
+
const intersectionRatio = totalArea > 0 ? visibleArea / totalArea : 0;
|
|
608
|
+
|
|
609
|
+
// Calculate if element is in preload zone (within PRELOAD_MARGIN_PX of viewport)
|
|
610
|
+
const isInPreloadZone =
|
|
611
|
+
rect.bottom > -PRELOAD_MARGIN_PX &&
|
|
612
|
+
rect.top < viewportHeight + PRELOAD_MARGIN_PX;
|
|
613
|
+
|
|
614
|
+
// Update visibility info
|
|
615
|
+
visibilityMapRef.current.set(mockupId, {
|
|
616
|
+
...existing,
|
|
617
|
+
intersectionRatio,
|
|
618
|
+
visibleArea,
|
|
619
|
+
boundingRect: rect,
|
|
620
|
+
distanceFromCenter,
|
|
621
|
+
isAboveViewport: rect.bottom < 0,
|
|
622
|
+
isInPreloadZone,
|
|
623
|
+
lastUpdated: Date.now(),
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
}, []);
|
|
627
|
+
|
|
628
|
+
// Visibility polling for priority updates
|
|
629
|
+
// Uses polling instead of scroll events for reliable cross-platform behavior
|
|
630
|
+
// (iOS momentum scrolling doesn't fire scroll events reliably)
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
|
|
633
|
+
const pollVisibility = () => {
|
|
634
|
+
updateVisibilityFromRefs();
|
|
635
|
+
recalculatePriorities();
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// Poll every 100ms for responsive updates on all platforms
|
|
639
|
+
const pollInterval = setInterval(pollVisibility, 100);
|
|
640
|
+
|
|
641
|
+
// Also listen to scroll for immediate response on desktop
|
|
642
|
+
// NOTE: Removed resize listener - it causes flash on Safari iPad when address bar
|
|
643
|
+
// shows/hides (triggers state updates). The 100ms poll handles resize cases adequately.
|
|
644
|
+
window.addEventListener('scroll', pollVisibility, { passive: true });
|
|
645
|
+
|
|
646
|
+
// Initial calculation
|
|
647
|
+
pollVisibility();
|
|
648
|
+
|
|
649
|
+
return () => {
|
|
650
|
+
clearInterval(pollInterval);
|
|
651
|
+
window.removeEventListener('scroll', pollVisibility);
|
|
652
|
+
};
|
|
653
|
+
}, [updateVisibilityFromRefs, recalculatePriorities]);
|
|
654
|
+
|
|
655
|
+
// --- Context Methods ---
|
|
656
|
+
|
|
657
|
+
const registerMockup = useCallback(
|
|
658
|
+
(mockupId: string, placement: string, initialPriority?: PriorityLevel, elementRef?: HTMLElement | null) => {
|
|
659
|
+
|
|
660
|
+
// Track registration order (if not already registered)
|
|
661
|
+
if (!registrationOrderRef.current.includes(mockupId)) {
|
|
662
|
+
registrationOrderRef.current.push(mockupId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Store element ref if provided
|
|
666
|
+
if (elementRef) {
|
|
667
|
+
elementRefsRef.current.set(mockupId, elementRef);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Initialize with default values
|
|
671
|
+
visibilityMapRef.current.set(mockupId, {
|
|
672
|
+
mockupId,
|
|
673
|
+
placement,
|
|
674
|
+
intersectionRatio: 0,
|
|
675
|
+
visibleArea: 0,
|
|
676
|
+
boundingRect: null,
|
|
677
|
+
distanceFromCenter: Infinity,
|
|
678
|
+
isAboveViewport: true,
|
|
679
|
+
isInPreloadZone: false,
|
|
680
|
+
lastUpdated: Date.now(),
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// Use initialPriority if provided, otherwise fall back to existing logic
|
|
684
|
+
if (initialPriority !== undefined) {
|
|
685
|
+
prioritiesRef.current.set(mockupId, initialPriority);
|
|
686
|
+
|
|
687
|
+
// Update level refs based on initial priority
|
|
688
|
+
if (initialPriority === 1 && !level1MockupRef.current) {
|
|
689
|
+
level1MockupRef.current = mockupId;
|
|
690
|
+
setLevel1MockupId(mockupId);
|
|
691
|
+
} else if (initialPriority === 2) {
|
|
692
|
+
if (!level2MockupsRef.current.includes(mockupId)) {
|
|
693
|
+
level2MockupsRef.current.push(mockupId);
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
// Level 3
|
|
697
|
+
if (!level3QueueRef.current.includes(mockupId)) {
|
|
698
|
+
level3QueueRef.current.push(mockupId);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Notify subscribers of initial priority
|
|
703
|
+
const callbacks = prioritySubscribersRef.current.get(mockupId);
|
|
704
|
+
if (callbacks) {
|
|
705
|
+
callbacks.forEach((callback) => callback(initialPriority));
|
|
706
|
+
}
|
|
707
|
+
} else {
|
|
708
|
+
// Fallback: Level 3 until visibility is reported
|
|
709
|
+
prioritiesRef.current.set(mockupId, 3);
|
|
710
|
+
|
|
711
|
+
// If this is the first mockup, assume it might be visible (above the fold)
|
|
712
|
+
if (prioritiesRef.current.size === 1 && !level1MockupRef.current) {
|
|
713
|
+
prioritiesRef.current.set(mockupId, 1);
|
|
714
|
+
level1MockupRef.current = mockupId;
|
|
715
|
+
setLevel1MockupId(mockupId);
|
|
716
|
+
} else {
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
[]
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const unregisterMockup = useCallback((mockupId: string) => {
|
|
724
|
+
visibilityMapRef.current.delete(mockupId);
|
|
725
|
+
prioritiesRef.current.delete(mockupId);
|
|
726
|
+
prioritySubscribersRef.current.delete(mockupId);
|
|
727
|
+
elementRefsRef.current.delete(mockupId);
|
|
728
|
+
|
|
729
|
+
// Remove from registration order
|
|
730
|
+
const orderIndex = registrationOrderRef.current.indexOf(mockupId);
|
|
731
|
+
if (orderIndex !== -1) {
|
|
732
|
+
registrationOrderRef.current.splice(orderIndex, 1);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// If this was Level 1, recalculate
|
|
736
|
+
if (level1MockupRef.current === mockupId) {
|
|
737
|
+
level1MockupRef.current = null;
|
|
738
|
+
setLevel1MockupId(null);
|
|
739
|
+
// Schedule recalculation to find new Level 1
|
|
740
|
+
scheduleRecalculation();
|
|
741
|
+
}
|
|
742
|
+
}, [scheduleRecalculation]);
|
|
743
|
+
|
|
744
|
+
// Update element ref (if element mounts after registration)
|
|
745
|
+
const updateElementRef = useCallback((mockupId: string, element: HTMLElement | null) => {
|
|
746
|
+
if (element) {
|
|
747
|
+
// Only store refs for visible elements (skip display:none)
|
|
748
|
+
// This handles responsive layouts with multiple HeroProductImage per mockupId
|
|
749
|
+
const rect = element.getBoundingClientRect();
|
|
750
|
+
if (rect.height === 0 || rect.width === 0) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
elementRefsRef.current.set(mockupId, element);
|
|
755
|
+
// Trigger visibility update AND priority recalculation
|
|
756
|
+
updateVisibilityFromRefs();
|
|
757
|
+
scheduleRecalculation();
|
|
758
|
+
} else {
|
|
759
|
+
elementRefsRef.current.delete(mockupId);
|
|
760
|
+
}
|
|
761
|
+
}, [updateVisibilityFromRefs, scheduleRecalculation]);
|
|
762
|
+
|
|
763
|
+
const reportVisibility = useCallback(
|
|
764
|
+
(mockupId: string, ratio: number, boundingRect: DOMRectReadOnly) => {
|
|
765
|
+
const existing = visibilityMapRef.current.get(mockupId);
|
|
766
|
+
if (!existing) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const viewportHeight =
|
|
771
|
+
typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
772
|
+
const viewportCenterY = viewportHeight / 2;
|
|
773
|
+
const elementCenterY = boundingRect.top + boundingRect.height / 2;
|
|
774
|
+
const distanceFromCenter = Math.abs(viewportCenterY - elementCenterY);
|
|
775
|
+
|
|
776
|
+
// Calculate visible area
|
|
777
|
+
const visibleTop = Math.max(0, boundingRect.top);
|
|
778
|
+
const visibleBottom = Math.min(viewportHeight, boundingRect.bottom);
|
|
779
|
+
const visibleLeft = Math.max(0, boundingRect.left);
|
|
780
|
+
const visibleRight = Math.min(
|
|
781
|
+
typeof window !== 'undefined' ? window.innerWidth : 1200,
|
|
782
|
+
boundingRect.right
|
|
783
|
+
);
|
|
784
|
+
const visibleWidth = Math.max(0, visibleRight - visibleLeft);
|
|
785
|
+
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
786
|
+
const visibleArea = visibleWidth * visibleHeight;
|
|
787
|
+
|
|
788
|
+
// Check if this is the first real visibility report for a visible mockup
|
|
789
|
+
// (transition from no data to visible)
|
|
790
|
+
const isFirstVisibleReport = existing.intersectionRatio === 0 && ratio > 0;
|
|
791
|
+
|
|
792
|
+
// Calculate if element is in preload zone (within PRELOAD_MARGIN_PX of viewport)
|
|
793
|
+
const isInPreloadZone =
|
|
794
|
+
boundingRect.bottom > -PRELOAD_MARGIN_PX &&
|
|
795
|
+
boundingRect.top < viewportHeight + PRELOAD_MARGIN_PX;
|
|
796
|
+
|
|
797
|
+
// Update visibility info
|
|
798
|
+
visibilityMapRef.current.set(mockupId, {
|
|
799
|
+
...existing,
|
|
800
|
+
intersectionRatio: ratio,
|
|
801
|
+
visibleArea,
|
|
802
|
+
boundingRect,
|
|
803
|
+
distanceFromCenter,
|
|
804
|
+
isAboveViewport: boundingRect.bottom < 0,
|
|
805
|
+
isInPreloadZone,
|
|
806
|
+
lastUpdated: Date.now(),
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// If this is the first visibility report for a visible mockup,
|
|
810
|
+
// trigger immediate recalculation to fix initial priorities quickly
|
|
811
|
+
if (isFirstVisibleReport) {
|
|
812
|
+
// Cancel any pending debounced recalculation
|
|
813
|
+
if (recalcTimeoutRef.current) {
|
|
814
|
+
clearTimeout(recalcTimeoutRef.current);
|
|
815
|
+
recalcTimeoutRef.current = null;
|
|
816
|
+
}
|
|
817
|
+
// Use requestAnimationFrame to batch multiple first-visible reports in the same frame
|
|
818
|
+
// Only schedule one rAF callback
|
|
819
|
+
if (pendingRafRef.current === null) {
|
|
820
|
+
pendingRafRef.current = requestAnimationFrame(() => {
|
|
821
|
+
pendingRafRef.current = null;
|
|
822
|
+
recalculatePriorities();
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
// Normal scroll: use debounced recalculation
|
|
827
|
+
scheduleRecalculation();
|
|
828
|
+
}
|
|
829
|
+
},
|
|
830
|
+
[scheduleRecalculation, recalculatePriorities]
|
|
831
|
+
);
|
|
832
|
+
|
|
833
|
+
// Mobile carousel mode - uses simple index-based priority
|
|
834
|
+
const reportMobileCarouselIndex = useCallback(
|
|
835
|
+
(currentIndex: number, mockupIds: string[]) => {
|
|
836
|
+
mobileCarouselRef.current = { currentIndex, mockupIds };
|
|
837
|
+
// Immediate recalculation with index-based priority
|
|
838
|
+
recalculatePriorities();
|
|
839
|
+
},
|
|
840
|
+
[recalculatePriorities]
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
const clearMobileCarouselMode = useCallback(() => {
|
|
844
|
+
mobileCarouselRef.current = null;
|
|
845
|
+
// Switch to visibility-based priority
|
|
846
|
+
recalculatePriorities();
|
|
847
|
+
}, [recalculatePriorities]);
|
|
848
|
+
|
|
849
|
+
const getPriorityLevel = useCallback((mockupId: string): PriorityLevel => {
|
|
850
|
+
return prioritiesRef.current.get(mockupId) || 3;
|
|
851
|
+
}, []);
|
|
852
|
+
|
|
853
|
+
const getQueuePriority = useCallback((mockupId: string): number => {
|
|
854
|
+
const level = prioritiesRef.current.get(mockupId) || 3;
|
|
855
|
+
switch (level) {
|
|
856
|
+
case 1: return 100;
|
|
857
|
+
case 2: return 50;
|
|
858
|
+
case 3: return 10;
|
|
859
|
+
default: return 10;
|
|
860
|
+
}
|
|
861
|
+
}, []);
|
|
862
|
+
|
|
863
|
+
const getLevel1Mockup = useCallback((): string | null => {
|
|
864
|
+
return level1MockupRef.current;
|
|
865
|
+
}, []);
|
|
866
|
+
|
|
867
|
+
const getLevel2Mockups = useCallback((): string[] => {
|
|
868
|
+
return [...level2MockupsRef.current];
|
|
869
|
+
}, []);
|
|
870
|
+
|
|
871
|
+
const getLevel3Queue = useCallback((): string[] => {
|
|
872
|
+
return [...level3QueueRef.current];
|
|
873
|
+
}, []);
|
|
874
|
+
|
|
875
|
+
const getVisibility = useCallback((mockupId: string): VisibilityInfo | null => {
|
|
876
|
+
return visibilityMapRef.current.get(mockupId) || null;
|
|
877
|
+
}, []);
|
|
878
|
+
|
|
879
|
+
const getMockupIdsForLevel = useCallback((level: PriorityLevel): string[] => {
|
|
880
|
+
switch (level) {
|
|
881
|
+
case 1:
|
|
882
|
+
return level1MockupRef.current ? [level1MockupRef.current] : [];
|
|
883
|
+
case 2:
|
|
884
|
+
return [...level2MockupsRef.current];
|
|
885
|
+
case 3:
|
|
886
|
+
return [...level3QueueRef.current];
|
|
887
|
+
default:
|
|
888
|
+
return [];
|
|
889
|
+
}
|
|
890
|
+
}, []);
|
|
891
|
+
|
|
892
|
+
const shouldPauseLevel = useCallback((level: 2 | 3): boolean => {
|
|
893
|
+
// Level 2 is paused when Level 1 is active
|
|
894
|
+
// Level 3 is paused when Level 1 or Level 2 is active
|
|
895
|
+
if (level === 2) {
|
|
896
|
+
return level1MockupRef.current !== null;
|
|
897
|
+
}
|
|
898
|
+
// level === 3
|
|
899
|
+
return (
|
|
900
|
+
level1MockupRef.current !== null || level2MockupsRef.current.length > 0
|
|
901
|
+
);
|
|
902
|
+
}, []);
|
|
903
|
+
|
|
904
|
+
const isLevel1Active = useCallback((): boolean => {
|
|
905
|
+
return level1MockupRef.current !== null;
|
|
906
|
+
}, []);
|
|
907
|
+
|
|
908
|
+
const subscribePriorityChange = useCallback(
|
|
909
|
+
(mockupId: string, callback: (level: PriorityLevel) => void) => {
|
|
910
|
+
if (!prioritySubscribersRef.current.has(mockupId)) {
|
|
911
|
+
prioritySubscribersRef.current.set(mockupId, new Set());
|
|
912
|
+
}
|
|
913
|
+
prioritySubscribersRef.current.get(mockupId)!.add(callback);
|
|
914
|
+
|
|
915
|
+
// Immediately call with current priority
|
|
916
|
+
const currentLevel = prioritiesRef.current.get(mockupId) || 3;
|
|
917
|
+
callback(currentLevel);
|
|
918
|
+
|
|
919
|
+
return () => {
|
|
920
|
+
const subscribers = prioritySubscribersRef.current.get(mockupId);
|
|
921
|
+
if (subscribers) {
|
|
922
|
+
subscribers.delete(callback);
|
|
923
|
+
if (subscribers.size === 0) {
|
|
924
|
+
prioritySubscribersRef.current.delete(mockupId);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
},
|
|
929
|
+
[]
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
const subscribeLevel1Change = useCallback(
|
|
933
|
+
(callback: (mockupId: string | null) => void) => {
|
|
934
|
+
level1SubscribersRef.current.add(callback);
|
|
935
|
+
// Immediately call with current Level 1
|
|
936
|
+
callback(level1MockupRef.current);
|
|
937
|
+
|
|
938
|
+
return () => {
|
|
939
|
+
level1SubscribersRef.current.delete(callback);
|
|
940
|
+
};
|
|
941
|
+
},
|
|
942
|
+
[]
|
|
943
|
+
);
|
|
944
|
+
|
|
945
|
+
// Create stable context value
|
|
946
|
+
const contextValue = useMemo<MockupPriorityContextValue>(
|
|
947
|
+
() => ({
|
|
948
|
+
registerMockup,
|
|
949
|
+
unregisterMockup,
|
|
950
|
+
updateElementRef,
|
|
951
|
+
reportVisibility,
|
|
952
|
+
reportMobileCarouselIndex,
|
|
953
|
+
clearMobileCarouselMode,
|
|
954
|
+
getPriorityLevel,
|
|
955
|
+
getQueuePriority,
|
|
956
|
+
getLevel1Mockup,
|
|
957
|
+
getLevel2Mockups,
|
|
958
|
+
getLevel3Queue,
|
|
959
|
+
getVisibility,
|
|
960
|
+
getMockupIdsForLevel,
|
|
961
|
+
shouldPauseLevel,
|
|
962
|
+
isLevel1Active,
|
|
963
|
+
subscribePriorityChange,
|
|
964
|
+
subscribeLevel1Change,
|
|
965
|
+
}),
|
|
966
|
+
[
|
|
967
|
+
registerMockup,
|
|
968
|
+
unregisterMockup,
|
|
969
|
+
updateElementRef,
|
|
970
|
+
reportVisibility,
|
|
971
|
+
reportMobileCarouselIndex,
|
|
972
|
+
clearMobileCarouselMode,
|
|
973
|
+
getPriorityLevel,
|
|
974
|
+
getQueuePriority,
|
|
975
|
+
getLevel1Mockup,
|
|
976
|
+
getLevel2Mockups,
|
|
977
|
+
getLevel3Queue,
|
|
978
|
+
getVisibility,
|
|
979
|
+
getMockupIdsForLevel,
|
|
980
|
+
shouldPauseLevel,
|
|
981
|
+
isLevel1Active,
|
|
982
|
+
subscribePriorityChange,
|
|
983
|
+
subscribeLevel1Change,
|
|
984
|
+
]
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
return (
|
|
988
|
+
<MockupPriorityContext.Provider value={contextValue}>
|
|
989
|
+
{children}
|
|
990
|
+
</MockupPriorityContext.Provider>
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Hook to access mockup priority context
|
|
996
|
+
* Throws if not inside MockupPriorityProvider
|
|
997
|
+
*/
|
|
998
|
+
export function useMockupPriority(): MockupPriorityContextValue {
|
|
999
|
+
const context = useContext(MockupPriorityContext);
|
|
1000
|
+
if (!context) {
|
|
1001
|
+
throw new Error(
|
|
1002
|
+
'useMockupPriority must be used within a MockupPriorityProvider'
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
return context;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Hook to optionally access mockup priority context
|
|
1010
|
+
* Returns undefined if not inside MockupPriorityProvider
|
|
1011
|
+
*/
|
|
1012
|
+
export function useMockupPriorityOptional(): MockupPriorityContextValue | undefined {
|
|
1013
|
+
return useContext(MockupPriorityContext);
|
|
1014
|
+
}
|