@snowcone-app/ui 0.1.42 → 0.2.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/CHANGELOG.md +33 -0
- package/README.md +18 -4
- 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 +1071 -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,608 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
export interface UseImageTransitionOptions {
|
|
6
|
+
/** Duration of image crossfade in ms (default: 500) */
|
|
7
|
+
fadeDuration?: number;
|
|
8
|
+
/** Delay before showing shimmer during slow loads in ms (default: 200) */
|
|
9
|
+
shimmerDelay?: number;
|
|
10
|
+
/** Duration of shimmer fade in/out in ms for light shimmer (default: 500) */
|
|
11
|
+
shimmerFadeDuration?: number;
|
|
12
|
+
/** Duration of dark shimmer fade-in in ms (default: 300) */
|
|
13
|
+
darkShimmerFadeInDuration?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Initial URL to display immediately without preloading.
|
|
16
|
+
* Use this when remounting a component that was already showing this image,
|
|
17
|
+
* allowing instant display without a network request or flicker.
|
|
18
|
+
*/
|
|
19
|
+
initialUrl?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A single image layer in the transition stack */
|
|
23
|
+
export interface ImageLayer {
|
|
24
|
+
url: string;
|
|
25
|
+
opacity: number;
|
|
26
|
+
id: number; // Unique ID for React key
|
|
27
|
+
waitForLoad?: boolean; // If true, don't auto-fade - wait for onImageLoad
|
|
28
|
+
loaded?: boolean; // True after img.decode() completes - controls visibility
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseImageTransitionReturn {
|
|
32
|
+
// What to render - a stack of image layers (bottom to top)
|
|
33
|
+
layers: ImageLayer[];
|
|
34
|
+
shimmerOpacity: number;
|
|
35
|
+
showShimmer: boolean;
|
|
36
|
+
shimmerType: "light" | "dark";
|
|
37
|
+
|
|
38
|
+
// Control
|
|
39
|
+
setTargetUrl: (url: string | null, shimmerType?: "light" | "dark") => void;
|
|
40
|
+
/**
|
|
41
|
+
* Add a URL directly to layers without preloading via new Image().
|
|
42
|
+
* The actual <img> element in the DOM will handle loading.
|
|
43
|
+
* Use this when you trust the URL and want the visible element to load it.
|
|
44
|
+
* @param options.skipTransition - If true, add at opacity 1 immediately (no fade-in)
|
|
45
|
+
*/
|
|
46
|
+
addLayerDirectly: (url: string, options?: { showShimmer?: boolean; skipTransition?: boolean }) => void;
|
|
47
|
+
/** Trigger shimmer immediately (e.g., when canvas export starts, before URL is known) */
|
|
48
|
+
triggerShimmer: (type?: "light" | "dark") => void;
|
|
49
|
+
/** Called when the actual img element loads - hides shimmer and triggers fade-in */
|
|
50
|
+
onImageLoad: (layerId: number) => void;
|
|
51
|
+
preloadUrl: string | null;
|
|
52
|
+
onPreloadComplete: (loadedUrl: string) => void;
|
|
53
|
+
onPreloadError: () => void;
|
|
54
|
+
onLayerTransitionEnd: (layerId: number) => void;
|
|
55
|
+
|
|
56
|
+
// Durations (for CSS styling)
|
|
57
|
+
durations: {
|
|
58
|
+
fade: number;
|
|
59
|
+
shimmerFade: number;
|
|
60
|
+
darkShimmerFadeIn: number;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let layerIdCounter = 0;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* useImageTransition - Simple image transition hook with race condition protection
|
|
68
|
+
*
|
|
69
|
+
* Core principle: Use a monotonically increasing version number to prevent stale
|
|
70
|
+
* image loads from being displayed. Each call to setTargetUrl increments the version,
|
|
71
|
+
* and only images that match the current version are added to layers.
|
|
72
|
+
*/
|
|
73
|
+
export function useImageTransition(
|
|
74
|
+
options: UseImageTransitionOptions = {}
|
|
75
|
+
): UseImageTransitionReturn {
|
|
76
|
+
const {
|
|
77
|
+
fadeDuration = 500,
|
|
78
|
+
shimmerDelay = 200,
|
|
79
|
+
shimmerFadeDuration = 500,
|
|
80
|
+
darkShimmerFadeInDuration = 300,
|
|
81
|
+
initialUrl,
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
// State - initialize with a layer if initialUrl is provided (for instant display on remount)
|
|
85
|
+
// When initialUrl is provided, we trust it's valid and display immediately (browser handles caching)
|
|
86
|
+
const [state, setState] = useState<{
|
|
87
|
+
layers: ImageLayer[];
|
|
88
|
+
preloadUrl: string | null;
|
|
89
|
+
shimmerOpacity: number;
|
|
90
|
+
showShimmer: boolean;
|
|
91
|
+
shimmerType: "light" | "dark";
|
|
92
|
+
}>(() => {
|
|
93
|
+
return {
|
|
94
|
+
layers: initialUrl
|
|
95
|
+
? [{ url: initialUrl, opacity: 1, id: ++layerIdCounter, loaded: true }]
|
|
96
|
+
: [],
|
|
97
|
+
preloadUrl: null,
|
|
98
|
+
shimmerOpacity: 0,
|
|
99
|
+
showShimmer: false,
|
|
100
|
+
shimmerType: "light",
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Version counter - increments on every setTargetUrl call
|
|
105
|
+
// This is the key to preventing race conditions
|
|
106
|
+
const versionRef = useRef(0);
|
|
107
|
+
|
|
108
|
+
// Current preloader
|
|
109
|
+
const preloaderRef = useRef<HTMLImageElement | null>(null);
|
|
110
|
+
|
|
111
|
+
// Track current preload URL for synchronous deduplication
|
|
112
|
+
// This is needed because setState is async and we need to prevent
|
|
113
|
+
// duplicate loads that happen in quick succession
|
|
114
|
+
const currentPreloadUrlRef = useRef<string | null>(null);
|
|
115
|
+
|
|
116
|
+
// Shimmer delay timer
|
|
117
|
+
const shimmerTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
118
|
+
|
|
119
|
+
// Track if mounted
|
|
120
|
+
const isMountedRef = useRef(true);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
isMountedRef.current = true;
|
|
124
|
+
return () => {
|
|
125
|
+
isMountedRef.current = false;
|
|
126
|
+
if (shimmerTimerRef.current) {
|
|
127
|
+
clearTimeout(shimmerTimerRef.current);
|
|
128
|
+
}
|
|
129
|
+
// DON'T cancel in-flight image loads on unmount!
|
|
130
|
+
// Let the browser finish loading and cache the response.
|
|
131
|
+
// When the component remounts, it will load instantly from cache.
|
|
132
|
+
// We just clear the callbacks to prevent state updates on unmounted component.
|
|
133
|
+
if (preloaderRef.current) {
|
|
134
|
+
preloaderRef.current.onload = null;
|
|
135
|
+
preloaderRef.current.onerror = null;
|
|
136
|
+
// NOTE: We intentionally do NOT set src = "" here
|
|
137
|
+
// Setting src = "" cancels the request before it completes
|
|
138
|
+
preloaderRef.current = null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
const setTargetUrl = useCallback(
|
|
144
|
+
(url: string | null, shimmerType: "light" | "dark" = "light") => {
|
|
145
|
+
// Increment version - this invalidates any in-flight preloads
|
|
146
|
+
const version = ++versionRef.current;
|
|
147
|
+
|
|
148
|
+
// Cancel any pending shimmer timer
|
|
149
|
+
if (shimmerTimerRef.current) {
|
|
150
|
+
clearTimeout(shimmerTimerRef.current);
|
|
151
|
+
shimmerTimerRef.current = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Cancel any in-flight preload
|
|
155
|
+
if (preloaderRef.current) {
|
|
156
|
+
preloaderRef.current.onload = null;
|
|
157
|
+
preloaderRef.current.onerror = null;
|
|
158
|
+
preloaderRef.current.src = "";
|
|
159
|
+
preloaderRef.current = null;
|
|
160
|
+
currentPreloadUrlRef.current = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle null/reset
|
|
164
|
+
if (!url) {
|
|
165
|
+
currentPreloadUrlRef.current = null;
|
|
166
|
+
setState({
|
|
167
|
+
layers: [],
|
|
168
|
+
preloadUrl: null,
|
|
169
|
+
shimmerOpacity: 0,
|
|
170
|
+
showShimmer: false,
|
|
171
|
+
shimmerType: "light",
|
|
172
|
+
});
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Track current preload URL in a ref for synchronous deduplication
|
|
177
|
+
// This prevents race conditions where multiple setTargetUrl calls happen
|
|
178
|
+
// before React state updates
|
|
179
|
+
if (currentPreloadUrlRef.current === url) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Check if we're already showing this URL
|
|
184
|
+
// Use functional update to safely check current state
|
|
185
|
+
let skipLoad = false;
|
|
186
|
+
setState((prev) => {
|
|
187
|
+
const topLayer = prev.layers[prev.layers.length - 1];
|
|
188
|
+
// Compare base URL without cache buster
|
|
189
|
+
const topLayerBaseUrl = topLayer?.url?.split('&_t=')[0]?.split('?_t=')[0];
|
|
190
|
+
if (topLayerBaseUrl === url && topLayer.opacity === 1) {
|
|
191
|
+
skipLoad = true;
|
|
192
|
+
return prev;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Start loading the new URL
|
|
196
|
+
return {
|
|
197
|
+
...prev,
|
|
198
|
+
preloadUrl: url,
|
|
199
|
+
shimmerType,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Note: skipLoad check here is a race but the ref check above handles most cases
|
|
204
|
+
// The worst case is starting a redundant load, which the server should handle
|
|
205
|
+
|
|
206
|
+
// Update ref to track current preload
|
|
207
|
+
currentPreloadUrlRef.current = url;
|
|
208
|
+
|
|
209
|
+
// Start the preload
|
|
210
|
+
const img = new Image();
|
|
211
|
+
img.crossOrigin = 'anonymous';
|
|
212
|
+
preloaderRef.current = img;
|
|
213
|
+
|
|
214
|
+
img.onload = () => {
|
|
215
|
+
// Clear the preload tracking ref
|
|
216
|
+
if (currentPreloadUrlRef.current === url) {
|
|
217
|
+
currentPreloadUrlRef.current = null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// CRITICAL: Only accept this image if it's still the current version
|
|
221
|
+
if (version !== versionRef.current) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!isMountedRef.current) return;
|
|
226
|
+
|
|
227
|
+
// Cancel shimmer timer since image loaded
|
|
228
|
+
if (shimmerTimerRef.current) {
|
|
229
|
+
clearTimeout(shimmerTimerRef.current);
|
|
230
|
+
shimmerTimerRef.current = null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
setState((prev) => {
|
|
234
|
+
// Double-check version inside setState for extra safety
|
|
235
|
+
if (version !== versionRef.current) {
|
|
236
|
+
return prev;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const newLayer: ImageLayer = {
|
|
240
|
+
url,
|
|
241
|
+
opacity: 0,
|
|
242
|
+
id: ++layerIdCounter,
|
|
243
|
+
loaded: false,
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Keep max 2 layers for performance
|
|
247
|
+
let newLayers: ImageLayer[];
|
|
248
|
+
if (prev.layers.length >= 2) {
|
|
249
|
+
const baseLayer = prev.layers.find((l) => l.opacity === 1) || prev.layers[0];
|
|
250
|
+
newLayers = baseLayer ? [baseLayer, newLayer] : [newLayer];
|
|
251
|
+
} else {
|
|
252
|
+
newLayers = [...prev.layers, newLayer];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...prev,
|
|
257
|
+
layers: newLayers,
|
|
258
|
+
preloadUrl: null,
|
|
259
|
+
shimmerOpacity: 0,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
img.onerror = () => {
|
|
265
|
+
// Check if this load is still relevant
|
|
266
|
+
if (version !== versionRef.current) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!isMountedRef.current) return;
|
|
270
|
+
|
|
271
|
+
// Clear the preload tracking ref
|
|
272
|
+
if (currentPreloadUrlRef.current === url) {
|
|
273
|
+
currentPreloadUrlRef.current = null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// DON'T hide shimmer on error!
|
|
277
|
+
// The URL might have been invalidated because a NEWER one is coming.
|
|
278
|
+
// Keep showing shimmer (or existing image) until a successful load.
|
|
279
|
+
setState((prev) => {
|
|
280
|
+
// If we have no layers (no image showing), show shimmer to indicate loading
|
|
281
|
+
// If we have layers, keep showing the existing image
|
|
282
|
+
const shouldShowShimmer = prev.layers.length === 0 || prev.showShimmer;
|
|
283
|
+
return {
|
|
284
|
+
...prev,
|
|
285
|
+
preloadUrl: null,
|
|
286
|
+
showShimmer: shouldShowShimmer,
|
|
287
|
+
shimmerOpacity: shouldShowShimmer ? 1 : 0,
|
|
288
|
+
shimmerType: prev.layers.length > 0 ? "dark" : "light",
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
img.src = url;
|
|
294
|
+
|
|
295
|
+
// Set up shimmer timer (show shimmer if load takes too long)
|
|
296
|
+
shimmerTimerRef.current = setTimeout(() => {
|
|
297
|
+
if (version !== versionRef.current) return;
|
|
298
|
+
if (!isMountedRef.current) return;
|
|
299
|
+
|
|
300
|
+
setState((prev) => {
|
|
301
|
+
if (!prev.preloadUrl) return prev; // Already loaded
|
|
302
|
+
return {
|
|
303
|
+
...prev,
|
|
304
|
+
showShimmer: true,
|
|
305
|
+
shimmerOpacity: 0,
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
}, shimmerDelay);
|
|
309
|
+
},
|
|
310
|
+
[shimmerDelay]
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
// Trigger fade-in when new layer is added at opacity 0
|
|
314
|
+
// Skip layers with waitForLoad - they will fade in via onImageLoad
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
const topLayer = state.layers[state.layers.length - 1];
|
|
317
|
+
if (topLayer && topLayer.opacity === 0 && !topLayer.waitForLoad) {
|
|
318
|
+
const targetId = topLayer.id;
|
|
319
|
+
|
|
320
|
+
// Double RAF for browser paint
|
|
321
|
+
const raf1 = requestAnimationFrame(() => {
|
|
322
|
+
if (!isMountedRef.current) return;
|
|
323
|
+
requestAnimationFrame(() => {
|
|
324
|
+
if (!isMountedRef.current) return;
|
|
325
|
+
setState((prev) => {
|
|
326
|
+
const idx = prev.layers.findIndex((l) => l.id === targetId);
|
|
327
|
+
if (idx === -1 || prev.layers[idx].opacity === 1) return prev;
|
|
328
|
+
|
|
329
|
+
const newLayers = [...prev.layers];
|
|
330
|
+
newLayers[idx] = { ...newLayers[idx], opacity: 1 };
|
|
331
|
+
return { ...prev, layers: newLayers };
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return () => cancelAnimationFrame(raf1);
|
|
337
|
+
}
|
|
338
|
+
}, [state.layers]);
|
|
339
|
+
|
|
340
|
+
// Trigger shimmer fade-in
|
|
341
|
+
useEffect(() => {
|
|
342
|
+
if (state.showShimmer && state.shimmerOpacity === 0 && state.preloadUrl) {
|
|
343
|
+
const raf = requestAnimationFrame(() => {
|
|
344
|
+
if (!isMountedRef.current) return;
|
|
345
|
+
requestAnimationFrame(() => {
|
|
346
|
+
if (!isMountedRef.current) return;
|
|
347
|
+
setState((prev) => {
|
|
348
|
+
if (!prev.showShimmer || !prev.preloadUrl) return prev;
|
|
349
|
+
return { ...prev, shimmerOpacity: 1 };
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
return () => cancelAnimationFrame(raf);
|
|
354
|
+
}
|
|
355
|
+
}, [state.showShimmer, state.shimmerOpacity, state.preloadUrl]);
|
|
356
|
+
|
|
357
|
+
// Fade out shimmer when load completes
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (state.showShimmer && state.shimmerOpacity === 0 && !state.preloadUrl) {
|
|
360
|
+
const duration = state.shimmerType === "dark" ? fadeDuration : shimmerFadeDuration;
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
setState((prev) => ({ ...prev, showShimmer: false }));
|
|
363
|
+
}, duration);
|
|
364
|
+
return () => clearTimeout(timer);
|
|
365
|
+
}
|
|
366
|
+
}, [state.showShimmer, state.shimmerOpacity, state.preloadUrl, state.shimmerType, fadeDuration, shimmerFadeDuration]);
|
|
367
|
+
|
|
368
|
+
const onLayerTransitionEnd = useCallback((layerId: number) => {
|
|
369
|
+
setState((prev) => {
|
|
370
|
+
const idx = prev.layers.findIndex((l) => l.id === layerId);
|
|
371
|
+
if (idx === -1 || prev.layers[idx].opacity !== 1) return prev;
|
|
372
|
+
|
|
373
|
+
// If this is the top layer and it's fully visible, remove layers below
|
|
374
|
+
if (idx === prev.layers.length - 1 && idx > 0) {
|
|
375
|
+
return {
|
|
376
|
+
...prev,
|
|
377
|
+
layers: [prev.layers[idx]],
|
|
378
|
+
shimmerOpacity: 0,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
return prev;
|
|
382
|
+
});
|
|
383
|
+
}, []);
|
|
384
|
+
|
|
385
|
+
// Legacy callbacks (for compatibility)
|
|
386
|
+
const onPreloadComplete = useCallback((_loadedUrl: string) => {
|
|
387
|
+
// Legacy callback - kept for API compatibility
|
|
388
|
+
}, []);
|
|
389
|
+
|
|
390
|
+
const onPreloadError = useCallback(() => {
|
|
391
|
+
// Legacy callback - kept for API compatibility
|
|
392
|
+
}, []);
|
|
393
|
+
|
|
394
|
+
// Add URL directly to layers without preloading via new Image()
|
|
395
|
+
// Instead, we create a hidden Image and call decode() to ensure Safari
|
|
396
|
+
// decodes the image BEFORE it enters the viewport (fixes Safari flash bug)
|
|
397
|
+
const addLayerDirectly = useCallback((url: string, options?: { showShimmer?: boolean; skipTransition?: boolean }) => {
|
|
398
|
+
const shouldShowShimmer = options?.showShimmer ?? true;
|
|
399
|
+
const skipTransition = options?.skipTransition ?? false;
|
|
400
|
+
|
|
401
|
+
// Increment version to invalidate any in-flight preloads
|
|
402
|
+
const currentVersion = ++versionRef.current;
|
|
403
|
+
|
|
404
|
+
// Cancel any pending preload
|
|
405
|
+
if (preloaderRef.current) {
|
|
406
|
+
preloaderRef.current.onload = null;
|
|
407
|
+
preloaderRef.current.onerror = null;
|
|
408
|
+
preloaderRef.current.src = "";
|
|
409
|
+
preloaderRef.current = null;
|
|
410
|
+
currentPreloadUrlRef.current = null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Cancel shimmer timer
|
|
414
|
+
if (shimmerTimerRef.current) {
|
|
415
|
+
clearTimeout(shimmerTimerRef.current);
|
|
416
|
+
shimmerTimerRef.current = null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const directUrl = url;
|
|
420
|
+
|
|
421
|
+
// Track the layer ID we're about to create
|
|
422
|
+
const newLayerId = layerIdCounter + 1;
|
|
423
|
+
|
|
424
|
+
setState((prev) => {
|
|
425
|
+
// Check if we already have this URL at opacity 1 (compare base URL without cache buster)
|
|
426
|
+
const topLayer = prev.layers[prev.layers.length - 1];
|
|
427
|
+
const topLayerBaseUrl = topLayer?.url?.split('&_t=')[0]?.split('?_t=')[0];
|
|
428
|
+
// Deduplicate if we're already loading OR showing this URL
|
|
429
|
+
// (Previously only checked opacity === 1, which caused duplicate layers during rapid updates)
|
|
430
|
+
if (topLayerBaseUrl === url) {
|
|
431
|
+
return prev;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Add new layer - either at opacity 0 (with fade-in) or opacity 1 (instant, for cached images)
|
|
435
|
+
// SAFARI FIX: When skipTransition is true, set loaded: true immediately to avoid
|
|
436
|
+
// any decode() calls or state changes that could cause flash on scroll.
|
|
437
|
+
const newLayer: ImageLayer = {
|
|
438
|
+
url: directUrl,
|
|
439
|
+
opacity: skipTransition ? 1 : 0,
|
|
440
|
+
id: ++layerIdCounter,
|
|
441
|
+
waitForLoad: skipTransition ? false : true,
|
|
442
|
+
loaded: skipTransition ? true : false,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Keep max 2 layers
|
|
446
|
+
let newLayers: ImageLayer[];
|
|
447
|
+
if (prev.layers.length >= 2) {
|
|
448
|
+
const baseLayer = prev.layers.find((l) => l.opacity === 1) || prev.layers[0];
|
|
449
|
+
newLayers = baseLayer ? [baseLayer, newLayer] : [newLayer];
|
|
450
|
+
} else {
|
|
451
|
+
newLayers = [...prev.layers, newLayer];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Preserve shimmer state - don't reset it here
|
|
455
|
+
// Shimmer will be hidden by onImageLoad when the img finishes loading
|
|
456
|
+
return {
|
|
457
|
+
...prev,
|
|
458
|
+
layers: newLayers,
|
|
459
|
+
};
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// SAFARI FIX: Skip decode() for skipTransition - avoids state changes that cause flash.
|
|
463
|
+
// When skipTransition is true, we trust the image is cached/ready and display it immediately.
|
|
464
|
+
// The decode() call is only needed for artwork changes where we want to ensure the new
|
|
465
|
+
// image is ready before showing it.
|
|
466
|
+
if (!skipTransition) {
|
|
467
|
+
// Proactively decode the image using a hidden Image element.
|
|
468
|
+
// Safari doesn't fire onLoad for off-screen images until they scroll into view,
|
|
469
|
+
// which causes a flash. By calling decode() here, we force Safari to decode
|
|
470
|
+
// the image immediately, and then mark it as loaded before it's visible.
|
|
471
|
+
const preloadImg = new Image();
|
|
472
|
+
preloadImg.crossOrigin = 'anonymous';
|
|
473
|
+
preloadImg.src = directUrl;
|
|
474
|
+
|
|
475
|
+
const markAsLoaded = () => {
|
|
476
|
+
// Check version to avoid stale updates
|
|
477
|
+
if (currentVersion !== versionRef.current) return;
|
|
478
|
+
if (!isMountedRef.current) return;
|
|
479
|
+
|
|
480
|
+
// Cancel shimmer timer since image is ready
|
|
481
|
+
if (shimmerTimerRef.current) {
|
|
482
|
+
clearTimeout(shimmerTimerRef.current);
|
|
483
|
+
shimmerTimerRef.current = null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
setState((prev) => {
|
|
487
|
+
const idx = prev.layers.findIndex((l) => l.id === newLayerId);
|
|
488
|
+
if (idx === -1 || prev.layers[idx].loaded) return prev;
|
|
489
|
+
|
|
490
|
+
const newLayers = [...prev.layers];
|
|
491
|
+
newLayers[idx] = { ...newLayers[idx], loaded: true, opacity: 1 };
|
|
492
|
+
return {
|
|
493
|
+
...prev,
|
|
494
|
+
layers: newLayers,
|
|
495
|
+
preloadUrl: null,
|
|
496
|
+
shimmerOpacity: 0,
|
|
497
|
+
};
|
|
498
|
+
});
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Use decode() if available (modern browsers), otherwise fall back to onload
|
|
502
|
+
if (preloadImg.decode) {
|
|
503
|
+
preloadImg.decode().then(markAsLoaded).catch(markAsLoaded);
|
|
504
|
+
} else {
|
|
505
|
+
preloadImg.onload = markAsLoaded;
|
|
506
|
+
preloadImg.onerror = markAsLoaded;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Start delayed shimmer timer - only show shimmer if image takes > 500ms to load
|
|
511
|
+
// This prevents flashing the dark overlay for cached/fast images
|
|
512
|
+
if (shouldShowShimmer) {
|
|
513
|
+
shimmerTimerRef.current = setTimeout(() => {
|
|
514
|
+
if (!isMountedRef.current) return;
|
|
515
|
+
if (currentVersion !== versionRef.current) return;
|
|
516
|
+
setState((prev) => {
|
|
517
|
+
// Only show shimmer if we still have a loading layer
|
|
518
|
+
const hasUnloadedLayer = prev.layers.some(l => !l.loaded);
|
|
519
|
+
if (!hasUnloadedLayer) return prev;
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
...prev,
|
|
523
|
+
showShimmer: true,
|
|
524
|
+
shimmerOpacity: 0,
|
|
525
|
+
shimmerType: "dark",
|
|
526
|
+
preloadUrl: "__pending__",
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
}, 500);
|
|
530
|
+
}
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
// Trigger shimmer immediately (for early feedback before URL is known)
|
|
534
|
+
const triggerShimmer = useCallback((type: "light" | "dark" = "dark") => {
|
|
535
|
+
setState((prev) => {
|
|
536
|
+
// Only trigger if we have existing layers (dark shimmer on top of content)
|
|
537
|
+
if (prev.layers.length === 0) {
|
|
538
|
+
return prev;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// If shimmer is already showing AND visible (opacity 1), skip
|
|
542
|
+
// But if shimmer was fading out (opacity 0), re-trigger it
|
|
543
|
+
if (prev.showShimmer && prev.shimmerOpacity === 1) {
|
|
544
|
+
return prev;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
...prev,
|
|
549
|
+
showShimmer: true,
|
|
550
|
+
shimmerOpacity: 0,
|
|
551
|
+
shimmerType: type,
|
|
552
|
+
// Set a placeholder preloadUrl to keep shimmer visible
|
|
553
|
+
// This will be replaced when setTargetUrl is called with the real URL
|
|
554
|
+
preloadUrl: prev.preloadUrl || "__pending__",
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
}, []);
|
|
558
|
+
|
|
559
|
+
// Called when the actual img element loads - hides shimmer AND triggers fade-in
|
|
560
|
+
const onImageLoad = useCallback((layerId: number) => {
|
|
561
|
+
// Cancel shimmer timer - image loaded before the 500ms delay
|
|
562
|
+
if (shimmerTimerRef.current) {
|
|
563
|
+
clearTimeout(shimmerTimerRef.current);
|
|
564
|
+
shimmerTimerRef.current = null;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
setState((prev) => {
|
|
568
|
+
// Find the layer
|
|
569
|
+
const idx = prev.layers.findIndex((l) => l.id === layerId);
|
|
570
|
+
if (idx === -1) {
|
|
571
|
+
return prev;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Set this layer to opacity 1 (will trigger CSS transition)
|
|
575
|
+
// and mark as loaded (for visibility control) since decode() completed
|
|
576
|
+
// Also hide shimmer since image is loaded
|
|
577
|
+
const newLayers = [...prev.layers];
|
|
578
|
+
newLayers[idx] = { ...newLayers[idx], opacity: 1, loaded: true };
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
...prev,
|
|
582
|
+
layers: newLayers,
|
|
583
|
+
preloadUrl: null,
|
|
584
|
+
shimmerOpacity: 0,
|
|
585
|
+
};
|
|
586
|
+
});
|
|
587
|
+
}, []);
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
layers: state.layers,
|
|
591
|
+
shimmerOpacity: state.shimmerOpacity,
|
|
592
|
+
showShimmer: state.showShimmer,
|
|
593
|
+
shimmerType: state.shimmerType,
|
|
594
|
+
setTargetUrl,
|
|
595
|
+
addLayerDirectly,
|
|
596
|
+
triggerShimmer,
|
|
597
|
+
onImageLoad,
|
|
598
|
+
preloadUrl: state.preloadUrl,
|
|
599
|
+
onPreloadComplete,
|
|
600
|
+
onPreloadError,
|
|
601
|
+
onLayerTransitionEnd,
|
|
602
|
+
durations: {
|
|
603
|
+
fade: fadeDuration,
|
|
604
|
+
shimmerFade: shimmerFadeDuration,
|
|
605
|
+
darkShimmerFadeIn: darkShimmerFadeInDuration,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { DesignElement } from "@snowcone-app/sdk";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* usePlacementsProcessor - Process placement prop to separate colors from images
|
|
8
|
+
*
|
|
9
|
+
* Takes a placements object where values can be either color codes or image URLs,
|
|
10
|
+
* and separates them into distinct arrays/objects for easier processing.
|
|
11
|
+
*
|
|
12
|
+
* **Color Detection:**
|
|
13
|
+
* - Hex colors: `#fff`, `#FF00FF`
|
|
14
|
+
* - RGB/RGBA: `rgb(255, 0, 0)`, `rgba(255, 0, 0, 0.5)`
|
|
15
|
+
* - HSL/HSLA: `hsl(120, 100%, 50%)`, `hsla(120, 100%, 50%, 0.3)`
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { processedImages, colorPlacements } = usePlacementsProcessor({
|
|
20
|
+
* Front: 'https://example.com/art.jpg',
|
|
21
|
+
* Back: '#FF0000',
|
|
22
|
+
* Sleeve: 'rgb(0, 255, 0)'
|
|
23
|
+
* }, []);
|
|
24
|
+
*
|
|
25
|
+
* // Result:
|
|
26
|
+
* // processedImages = [{ placement: 'Front', imageUrl: 'https://example.com/art.jpg' }]
|
|
27
|
+
* // colorPlacements = { Back: '#FF0000', Sleeve: 'rgb(0, 255, 0)' }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* @param placements - Object mapping placement labels to color codes or image URLs
|
|
31
|
+
* @param fallbackImages - Images array to return if no placements provided
|
|
32
|
+
* @returns Object with processedImages array and colorPlacements object
|
|
33
|
+
*/
|
|
34
|
+
export function usePlacementsProcessor(
|
|
35
|
+
placements: Record<string, string> | undefined,
|
|
36
|
+
fallbackImages: DesignElement[]
|
|
37
|
+
): {
|
|
38
|
+
processedImages: DesignElement[];
|
|
39
|
+
colorPlacements: Record<string, string> | null;
|
|
40
|
+
} {
|
|
41
|
+
return useMemo(() => {
|
|
42
|
+
if (!placements) {
|
|
43
|
+
return { processedImages: fallbackImages, colorPlacements: null };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const imgPlacements: DesignElement[] = [];
|
|
47
|
+
const colors: Record<string, string> = {};
|
|
48
|
+
|
|
49
|
+
// Helper to detect if value is a color (hex, rgb, rgba, hsl, hsla)
|
|
50
|
+
const isColor = (value: string): boolean => {
|
|
51
|
+
return (
|
|
52
|
+
/^#([0-9A-F]{3}){1,2}$/i.test(value) || // hex
|
|
53
|
+
/^rgb/.test(value) || // rgb/rgba
|
|
54
|
+
/^hsl/.test(value) // hsl/hsla
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
Object.entries(placements).forEach(([placementLabel, value]) => {
|
|
59
|
+
if (isColor(value)) {
|
|
60
|
+
colors[placementLabel] = value;
|
|
61
|
+
} else {
|
|
62
|
+
imgPlacements.push({
|
|
63
|
+
placement: placementLabel,
|
|
64
|
+
imageUrl: value,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
processedImages: imgPlacements.length > 0 ? imgPlacements : fallbackImages,
|
|
71
|
+
colorPlacements: Object.keys(colors).length > 0 ? colors : null,
|
|
72
|
+
};
|
|
73
|
+
}, [placements, fallbackImages]);
|
|
74
|
+
}
|