@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.
Files changed (196) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +18 -4
  3. package/dist/index.cjs +5 -2
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.js +5 -2
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -5
  8. package/src/components/CanvasIsolationBoundary.tsx +202 -0
  9. package/src/components/LoadingOverlayPrism.tsx +251 -0
  10. package/src/composed/AddToCart.tsx +229 -0
  11. package/src/composed/ArtAlignment.tsx +703 -0
  12. package/src/composed/ArtSelector.tsx +290 -0
  13. package/src/composed/ArtworkCustomizer.tsx +212 -0
  14. package/src/composed/CanvasEditor.tsx +79 -0
  15. package/src/composed/ColorPicker.tsx +111 -0
  16. package/src/composed/CurrentSelectionDisplay.tsx +86 -0
  17. package/src/composed/HeroProductImage.tsx +1079 -0
  18. package/src/composed/Lightbox.index.ts +2 -0
  19. package/src/composed/Lightbox.tsx +230 -0
  20. package/src/composed/PlacementClipShapeSelector.tsx +88 -0
  21. package/src/composed/PlacementTabs.tsx +179 -0
  22. package/src/composed/ProductCard.tsx +298 -0
  23. package/src/composed/ProductGallery.tsx +54 -0
  24. package/src/composed/ProductImage.tsx +129 -0
  25. package/src/composed/ProductList.tsx +147 -0
  26. package/src/composed/ProductOptions.tsx +305 -0
  27. package/src/composed/RealtimeMockup.tsx +121 -0
  28. package/src/composed/TileCount.tsx +348 -0
  29. package/src/composed/carousels/HeroCarousel.tsx +240 -0
  30. package/src/composed/carousels/MobileProductCarousel.tsx +1002 -0
  31. package/src/composed/carousels/index.ts +11 -0
  32. package/src/composed/carousels/types.ts +58 -0
  33. package/src/composed/grids/MasonryGrid.tsx +238 -0
  34. package/src/composed/grids/index.ts +9 -0
  35. package/src/composed/search/CurrentRefinements.tsx +80 -0
  36. package/src/composed/search/Filters.tsx +49 -0
  37. package/src/composed/search/FiltersButton.tsx +57 -0
  38. package/src/composed/search/FiltersDrawer.tsx +375 -0
  39. package/src/composed/search/ProductGrid.tsx +118 -0
  40. package/src/composed/search/ProductHit.tsx +56 -0
  41. package/src/composed/search/SearchBox.tsx +109 -0
  42. package/src/composed/search/SearchProvider.tsx +136 -0
  43. package/src/composed/search/facetConfig.ts +16 -0
  44. package/src/composed/search/index.ts +22 -0
  45. package/src/composed/search/meilisearchAdapter.ts +20 -0
  46. package/src/composed/search/types.ts +22 -0
  47. package/src/composed/zoom/EnhancedImageViewer.tsx +505 -0
  48. package/src/composed/zoom/ResponsiveZoom.tsx +134 -0
  49. package/src/composed/zoom/ZoomOverlay.tsx +194 -0
  50. package/src/composed/zoom/index.ts +12 -0
  51. package/src/composed/zoom/types.ts +12 -0
  52. package/src/design-system/ColorPalette.tsx +126 -0
  53. package/src/design-system/ColorSwatch.tsx +49 -0
  54. package/src/design-system/DesignSystemPage.tsx +130 -0
  55. package/src/design-system/ThemeSwitcher.tsx +181 -0
  56. package/src/design-system/TypographyScale.tsx +106 -0
  57. package/src/design-system/index.ts +5 -0
  58. package/src/ecommerce/stories/HeroProductImage.stories.tsx +66 -0
  59. package/src/ecommerce/stories/PDPHeroGallery.stories.tsx +105 -0
  60. package/src/ecommerce/stories/PDPInfoPanel.stories.tsx +472 -0
  61. package/src/ecommerce/stories/PDPLayout.stories.tsx +365 -0
  62. package/src/hooks/useBrand.ts +41 -0
  63. package/src/hooks/useCanvasContext.ts +127 -0
  64. package/src/hooks/useDeviceDetection.ts +64 -0
  65. package/src/hooks/useFocusTrap.ts +70 -0
  66. package/src/hooks/useImagePreloader.ts +268 -0
  67. package/src/hooks/useImageTransition.ts +608 -0
  68. package/src/hooks/usePlacementsProcessor.ts +74 -0
  69. package/src/hooks/useProductGallery.ts +193 -0
  70. package/src/hooks/useProductPage.ts +467 -0
  71. package/src/hooks/useRenderGuard.ts +96 -0
  72. package/src/hooks/useScrollDirection.ts +196 -0
  73. package/src/hooks/viewport/index.ts +25 -0
  74. package/src/hooks/viewport/useContainerWidth.ts +59 -0
  75. package/src/hooks/viewport/useMediaQuery.ts +52 -0
  76. package/src/hooks/viewport/useResponsiveImageCap.ts +149 -0
  77. package/src/hooks/viewport/useViewportDimensions.ts +135 -0
  78. package/src/hooks/viewport/useWideMonitorMode.ts +150 -0
  79. package/src/hooks/visibility/index.ts +15 -0
  80. package/src/hooks/visibility/observerPool.ts +150 -0
  81. package/src/index.ts +240 -0
  82. package/src/layouts/hero-zoom/HeroShrinkLayout.tsx +209 -0
  83. package/src/layouts/hero-zoom/HeroZoomLayout.tsx +351 -0
  84. package/src/layouts/hero-zoom/index.ts +30 -0
  85. package/src/layouts/hero-zoom/stories/HeroZoomLayout.stories.tsx +350 -0
  86. package/src/layouts/hero-zoom/types.ts +113 -0
  87. package/src/layouts/hero-zoom/useHeroZoomScales.ts +156 -0
  88. package/src/layouts/index.ts +9 -0
  89. package/src/layouts/pdp/EdgeBlurBox.tsx +210 -0
  90. package/src/layouts/pdp/ImageBlurExtension.tsx +215 -0
  91. package/src/layouts/pdp/ImageEdgeBlur.tsx +215 -0
  92. package/src/layouts/pdp/PDPLayout.tsx +246 -0
  93. package/src/layouts/pdp/SimpleImageBlur.tsx +140 -0
  94. package/src/layouts/pdp/index.ts +40 -0
  95. package/src/lib/env.ts +15 -0
  96. package/src/lib/locale.ts +167 -0
  97. package/src/lib/router.tsx +46 -0
  98. package/src/lib/utils.ts +6 -0
  99. package/src/lightbox/README.md +77 -0
  100. package/src/next/index.tsx +26 -0
  101. package/src/patterns/MockupPriorityProvider.tsx +1014 -0
  102. package/src/patterns/Product.tsx +850 -0
  103. package/src/patterns/ProductPageProvider.tsx +224 -0
  104. package/src/patterns/RealtimeProvider.tsx +1162 -0
  105. package/src/patterns/ShopProvider.tsx +603 -0
  106. package/src/personalization/PersonalizationBridge.tsx +235 -0
  107. package/src/personalization/PersonalizationContext.ts +29 -0
  108. package/src/personalization/PersonalizationInputs.tsx +110 -0
  109. package/src/personalization/PersonalizationProvider.tsx +407 -0
  110. package/src/personalization/canvas-stub.d.ts +22 -0
  111. package/src/personalization/index.ts +43 -0
  112. package/src/personalization/types.ts +48 -0
  113. package/src/personalization/usePersonalization.ts +32 -0
  114. package/src/personalization/usePersonalizationShimmer.ts +159 -0
  115. package/src/personalization/utils.ts +59 -0
  116. package/src/primitives/BrandLogo.tsx +65 -0
  117. package/src/primitives/BrandName.tsx +51 -0
  118. package/src/primitives/Button.tsx +123 -0
  119. package/src/primitives/ColorSwatch.tsx +221 -0
  120. package/src/primitives/DragHintAnimation.tsx +190 -0
  121. package/src/primitives/EdgeSwipeGuards.tsx +60 -0
  122. package/src/primitives/FloatingActionGroup.tsx +176 -0
  123. package/src/primitives/ProductPrice.tsx +171 -0
  124. package/src/primitives/ProgressiveBlur.tsx +295 -0
  125. package/src/primitives/ThemeToggle.tsx +125 -0
  126. package/src/primitives/__tests__/story-coverage.test.ts +98 -0
  127. package/src/primitives/accordion.tsx +280 -0
  128. package/src/primitives/badge.tsx +137 -0
  129. package/src/primitives/card.tsx +61 -0
  130. package/src/primitives/checkbox.tsx +56 -0
  131. package/src/primitives/collapsible.tsx +51 -0
  132. package/src/primitives/drawer.tsx +828 -0
  133. package/src/primitives/dropdown-menu.tsx +197 -0
  134. package/src/primitives/fieldset.tsx +73 -0
  135. package/src/primitives/index.ts +138 -0
  136. package/src/primitives/input.tsx +91 -0
  137. package/src/primitives/kbd.tsx +130 -0
  138. package/src/primitives/label.tsx +20 -0
  139. package/src/primitives/link.tsx +182 -0
  140. package/src/primitives/popover.tsx +80 -0
  141. package/src/primitives/radio-group.tsx +79 -0
  142. package/src/primitives/scroll-fade.tsx +159 -0
  143. package/src/primitives/select.tsx +170 -0
  144. package/src/primitives/separator.tsx +25 -0
  145. package/src/primitives/slider.tsx +221 -0
  146. package/src/primitives/spinner.tsx +72 -0
  147. package/src/primitives/stories/Accordion.stories.tsx +121 -0
  148. package/src/primitives/stories/Badge.stories.tsx +221 -0
  149. package/src/primitives/stories/Button.stories.tsx +185 -0
  150. package/src/primitives/stories/Card.stories.tsx +171 -0
  151. package/src/primitives/stories/Checkbox.stories.tsx +214 -0
  152. package/src/primitives/stories/Collapsible.stories.tsx +230 -0
  153. package/src/primitives/stories/Drawer.stories.tsx +378 -0
  154. package/src/primitives/stories/DropdownMenu.stories.tsx +182 -0
  155. package/src/primitives/stories/Fieldset.stories.tsx +212 -0
  156. package/src/primitives/stories/Input.stories.tsx +172 -0
  157. package/src/primitives/stories/Kbd.stories.tsx +183 -0
  158. package/src/primitives/stories/Label.stories.tsx +98 -0
  159. package/src/primitives/stories/Link.stories.tsx +260 -0
  160. package/src/primitives/stories/Popover.stories.tsx +178 -0
  161. package/src/primitives/stories/RadioGroup.stories.tsx +205 -0
  162. package/src/primitives/stories/Select.stories.tsx +222 -0
  163. package/src/primitives/stories/Separator.stories.tsx +134 -0
  164. package/src/primitives/stories/Slider.stories.tsx +203 -0
  165. package/src/primitives/stories/Spinner.stories.tsx +142 -0
  166. package/src/primitives/stories/Surface.stories.tsx +257 -0
  167. package/src/primitives/stories/Switch.stories.tsx +131 -0
  168. package/src/primitives/stories/Tabs.stories.tsx +275 -0
  169. package/src/primitives/stories/TextField.stories.tsx +139 -0
  170. package/src/primitives/stories/Textarea.stories.tsx +148 -0
  171. package/src/primitives/stories/Tooltip.stories.tsx +119 -0
  172. package/src/primitives/surface.tsx +86 -0
  173. package/src/primitives/switch.tsx +35 -0
  174. package/src/primitives/tabs.tsx +206 -0
  175. package/src/primitives/text-field.tsx +84 -0
  176. package/src/primitives/textarea.tsx +50 -0
  177. package/src/primitives/tooltip.tsx +58 -0
  178. package/src/services/CanvasExportService.ts +518 -0
  179. package/src/styles/base.css +380 -0
  180. package/src/styles/defaults.css +280 -0
  181. package/src/styles/globals.css +1242 -0
  182. package/src/styles/index.css +17 -0
  183. package/src/styles/ne-themes.css +4740 -0
  184. package/src/styles/tailwind.css +11 -0
  185. package/src/styles/tokens.css +117 -0
  186. package/src/styles/utilities.css +188 -0
  187. package/src/themes/apply-theme.ts +449 -0
  188. package/src/themes/getThemeStyles.ts +454 -0
  189. package/src/themes/index.ts +48 -0
  190. package/src/themes/oklch-theme.ts +283 -0
  191. package/src/themes/presets.ts +989 -0
  192. package/src/themes/types.ts +386 -0
  193. package/src/themes/useTheme.tsx +450 -0
  194. package/src/utils/dev-warnings.ts +161 -0
  195. package/src/utils/devWarnings.ts +153 -0
  196. package/dist/styles.css +0 -1
@@ -0,0 +1,703 @@
1
+ "use client";
2
+
3
+ import React, {
4
+ useRef,
5
+ useState,
6
+ useEffect,
7
+ useMemo,
8
+ type CSSProperties,
9
+ } from "react";
10
+ import { useDrag } from "@use-gesture/react";
11
+ import {
12
+ describeProductArtAlignment,
13
+ getSnapPoints,
14
+ type ProductArtAlignmentOptions,
15
+ type ProductArtAlignmentContext,
16
+ type ImageAlignment,
17
+ } from "@snowcone-app/sdk";
18
+ import { useProduct, useDesignOptional } from "../patterns/Product";
19
+
20
+ export interface ArtAlignmentProps extends ProductArtAlignmentOptions {
21
+ context?: ProductArtAlignmentContext;
22
+ placement?: string;
23
+ height?: string | number;
24
+ maxHeight?: string | number;
25
+ minHeight?: string | number;
26
+ artwork?: {
27
+ src: string;
28
+ // aspectRatio intentionally omitted - auto-detected from image
29
+ };
30
+ }
31
+
32
+ /**
33
+ * ArtAlignment - Interactive artwork positioning component
34
+ *
35
+ * Displays an artwork image with a draggable frame overlay for selecting the alignment
36
+ * position within a product placement. The frame snaps to predefined positions based on
37
+ * the available movement space.
38
+ *
39
+ * **🚨 DO NOT USE THIS COMPONENT DIRECTLY!**
40
+ * **Use `<ArtworkCustomizer />` instead - it automatically handles both regular artwork AND patterns.**
41
+ *
42
+ * **Why use ArtworkCustomizer:**
43
+ * - ✅ Single component for both regular artwork and patterns
44
+ * - ✅ TypeScript enforces correct props based on artwork type
45
+ * - ✅ Impossible to make mistakes (no manual conditionals needed)
46
+ * - ✅ Cleaner, simpler code
47
+ *
48
+ * **Only use ArtAlignment directly if:**
49
+ * - You are 100% certain you only have regular artwork (never patterns)
50
+ * - You are building a custom component that wraps ArtAlignment
51
+ * - You have read the ArtworkCustomizer docs and understand why you need direct access
52
+ *
53
+ * **⚠️ CRITICAL: If you use ArtAlignment directly:**
54
+ * - ONLY for regular artwork (photos, illustrations) - NEVER for seamless patterns
55
+ * - For seamless patterns, use `TileCount` component instead
56
+ * - NEVER show both ArtAlignment and TileCount at the same time
57
+ * - Check `artwork.type === 'seamless'` to decide which component to render
58
+ * - Pattern: `{artwork.type !== 'seamless' ? <ArtAlignment /> : <TileCount />}`
59
+ *
60
+ * **👉 See ArtworkCustomizer for the recommended approach!**
61
+ *
62
+ * **🎯 Automatic Aspect Ratio Detection:**
63
+ * - **DO NOT** pass `aspectRatio` prop - it is intentionally not supported
64
+ * - ArtAlignment automatically detects aspect ratio from the loaded image
65
+ * - This prevents layout issues from incorrect manual aspect ratios
66
+ * - The component will wait for the image to load before calculating dimensions
67
+ *
68
+ * **Smart Snap Points:**
69
+ * - 1 position (center only): When artwork and placement aspect ratios are nearly identical (< 5% movement)
70
+ * - 3 positions (far-left/top, center, far-right/bottom): Limited movement space (5-15%)
71
+ * - 5 positions (adds left/top, right/bottom): Significant movement space (≥ 15%)
72
+ *
73
+ * **Context Integration:**
74
+ * - Works within `Product` context to get placement dimensions
75
+ * - Integrates with `Design` context for artwork and alignment state
76
+ * - Automatically calculates mask aspect ratio from product placements
77
+ *
78
+ * **Features:**
79
+ * - Theme-aware borders using CSS variables
80
+ * - Blur effect outside the selected frame area
81
+ * - Touch and mouse drag support
82
+ * - Automatic aspect ratio detection from image
83
+ * - Smooth snapping to alignment positions
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * // Basic usage with artwork prop (aspect ratio auto-detected)
88
+ * <Product productId="shirt-123">
89
+ * <ArtAlignment
90
+ * artwork={{ src: 'https://example.com/art.jpg' }}
91
+ * placement="Front"
92
+ * maxHeight={200}
93
+ * />
94
+ * </Product>
95
+ * ```
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * // Using context-based artwork (from Design context)
100
+ * <Product productId="shirt-123">
101
+ * <ArtAlignment
102
+ * placement="Front"
103
+ * maxHeight={200}
104
+ * />
105
+ * </Product>
106
+ * ```
107
+ *
108
+ * @param artwork - Artwork object with src only (aspect ratio auto-detected from image)
109
+ * @param src - Image source URL (alternative to artwork prop, deprecated - use artwork.src instead)
110
+ * @param placement - Product placement label (e.g., "Front", "Back"). Updates Design context automatically when alignment changes
111
+ * @param maskAspectRatio - Manual mask aspect ratio (usually auto-detected from placement)
112
+ * @param alignment - Initial alignment position
113
+ * @param maxHeight - Maximum height constraint in pixels
114
+ * @param height - Fixed height in pixels
115
+ * @param minHeight - Minimum height constraint in pixels
116
+ * @param className - Additional CSS classes
117
+ */
118
+ export function ArtAlignment({
119
+ src: propSrc,
120
+ artworkAspectRatio: propAspectRatio,
121
+ maskAspectRatio,
122
+ placement,
123
+ alignment: propAlignment,
124
+ className,
125
+ context,
126
+ height,
127
+ maxHeight,
128
+ minHeight,
129
+ artwork,
130
+ }: ArtAlignmentProps) {
131
+ // Get design context
132
+ const designContext = useDesignOptional();
133
+
134
+ // Get design for this placement if available
135
+ const placementDesign =
136
+ placement && designContext
137
+ ? designContext.getPlacementDesign(placement)
138
+ : undefined;
139
+
140
+ // Use artwork from props first, then context/placement, then individual props
141
+ const src =
142
+ artwork?.src ||
143
+ placementDesign?.imageUrl ||
144
+ designContext?.selectedArtwork?.src ||
145
+ propSrc;
146
+
147
+ // IMPORTANT: aspectRatio is intentionally NOT used from props
148
+ // ArtAlignment auto-detects aspect ratio from the loaded image
149
+ // This prevents incorrect manual aspect ratios from breaking layout
150
+ const artworkAspectRatio = undefined; // Always auto-detect from image
151
+
152
+ // Try to get product from context - call hook unconditionally
153
+ let productCtx: any;
154
+ try {
155
+ productCtx = useProduct();
156
+ } catch {
157
+ // Not in a Product context, that's OK
158
+ productCtx = null;
159
+ }
160
+
161
+ // Build product context after hook calls
162
+ let productContext: ProductArtAlignmentContext | undefined = context;
163
+ if (!productContext && productCtx) {
164
+ // Get placements from product if available (they're added dynamically)
165
+ const product = productCtx.product as any;
166
+ productContext = {
167
+ product: {
168
+ placements: product?.placements,
169
+ },
170
+ selection: {},
171
+ };
172
+ }
173
+
174
+ const containerRef = useRef<HTMLDivElement>(null);
175
+ const imageRef = useRef<HTMLImageElement>(null);
176
+
177
+ // Track when position was last set by user drag to prevent sync effect from causing "boomerang"
178
+ // The boomerang happens because: drag sets position -> context updates async -> sync effect fires -> resets position
179
+ // We ignore context-driven updates for 500ms after a user drag
180
+ const lastUserDragTimeRef = useRef(0);
181
+
182
+ // All hooks must be called before any early returns
183
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
184
+ const [position, setPosition] = useState({ x: 0, y: 0 });
185
+ const [isReady, setIsReady] = useState(false);
186
+ const [detectedAspectRatio, setDetectedAspectRatio] = useState<number | undefined>();
187
+
188
+ // Use detected aspect ratio if context doesn't provide one
189
+ const effectiveAspectRatio = artworkAspectRatio || detectedAspectRatio;
190
+
191
+ // Derive alignment from context or props - don't use local state for this
192
+ const alignment = placementDesign?.alignment || propAlignment || "center";
193
+
194
+ const descriptor = useMemo(
195
+ () =>
196
+ describeProductArtAlignment(
197
+ {
198
+ src,
199
+ artworkAspectRatio: effectiveAspectRatio,
200
+ maskAspectRatio,
201
+ placement,
202
+ alignment: propAlignment,
203
+ className,
204
+ },
205
+ productContext
206
+ ),
207
+ [
208
+ src,
209
+ effectiveAspectRatio,
210
+ maskAspectRatio,
211
+ placement,
212
+ propAlignment,
213
+ className,
214
+ productContext,
215
+ ]
216
+ );
217
+
218
+ // Calculate container style with height constraints
219
+ const containerStyle = useMemo(() => {
220
+ const style: CSSProperties = {};
221
+
222
+ // When height is constrained, we need to calculate the width based on aspect ratio
223
+ if (height !== undefined) {
224
+ const heightValue =
225
+ typeof height === "number" ? height : parseFloat(height);
226
+ if (!isNaN(heightValue) && effectiveAspectRatio) {
227
+ style.height = `${heightValue}px`;
228
+ style.width = `${heightValue * effectiveAspectRatio}px`;
229
+ }
230
+ } else if (maxHeight !== undefined) {
231
+ const maxHeightValue =
232
+ typeof maxHeight === "number" ? maxHeight : parseFloat(maxHeight);
233
+ if (!isNaN(maxHeightValue) && effectiveAspectRatio) {
234
+ style.maxHeight = `${maxHeightValue}px`;
235
+ style.maxWidth = `${maxHeightValue * effectiveAspectRatio}px`;
236
+ style.width = "100%";
237
+ style.aspectRatio = String(effectiveAspectRatio);
238
+ }
239
+ } else if (effectiveAspectRatio) {
240
+ // Default behavior - use aspect ratio
241
+ style.aspectRatio = String(effectiveAspectRatio);
242
+ style.width = "100%";
243
+ }
244
+
245
+ if (minHeight !== undefined) {
246
+ const minHeightValue =
247
+ typeof minHeight === "number" ? minHeight : parseFloat(minHeight);
248
+ if (!isNaN(minHeightValue)) {
249
+ style.minHeight = `${minHeightValue}px`;
250
+ if (effectiveAspectRatio) {
251
+ style.minWidth = `${minHeightValue * effectiveAspectRatio}px`;
252
+ }
253
+ }
254
+ }
255
+
256
+ return style;
257
+ }, [effectiveAspectRatio, height, maxHeight, minHeight]);
258
+
259
+ // Initialize container size measurement using ResizeObserver (avoids forced reflow)
260
+ useEffect(() => {
261
+ if (!containerRef.current) return;
262
+
263
+ // Use ResizeObserver for all size measurements - it runs asynchronously
264
+ // and doesn't cause forced synchronous layout like getBoundingClientRect()
265
+ const resizeObserver = new ResizeObserver((entries) => {
266
+ const entry = entries[0];
267
+ if (entry) {
268
+ // Use contentRect which is already computed - no forced reflow
269
+ const { width, height } = entry.contentRect;
270
+ setContainerSize((prev) => {
271
+ // Only update if size actually changed to avoid unnecessary re-renders
272
+ if (prev.width === width && prev.height === height) return prev;
273
+ return { width, height };
274
+ });
275
+ }
276
+ });
277
+
278
+ resizeObserver.observe(containerRef.current);
279
+
280
+ return () => {
281
+ resizeObserver.disconnect();
282
+ };
283
+ }, []);
284
+
285
+ // Handle image load
286
+ const handleImageLoad = () => {
287
+ setIsReady(true);
288
+
289
+ // Detect aspect ratio from loaded image
290
+ if (imageRef.current && !artworkAspectRatio) {
291
+ const { naturalWidth, naturalHeight } = imageRef.current;
292
+ if (naturalWidth && naturalHeight) {
293
+ setDetectedAspectRatio(naturalWidth / naturalHeight);
294
+ }
295
+ }
296
+ // Note: Container size is handled by ResizeObserver - no need for getBoundingClientRect()
297
+ };
298
+
299
+ // Check if the image is already loaded when component mounts
300
+ useEffect(() => {
301
+ if (!imageRef.current || !descriptor) return;
302
+
303
+ // If the image already has a complete property set to true,
304
+ // it means the image is already loaded (from cache)
305
+ if (imageRef.current.complete) {
306
+ setIsReady(true);
307
+
308
+ // Detect aspect ratio for cached images
309
+ if (!artworkAspectRatio) {
310
+ const { naturalWidth, naturalHeight } = imageRef.current;
311
+ if (naturalWidth && naturalHeight) {
312
+ setDetectedAspectRatio(naturalWidth / naturalHeight);
313
+ }
314
+ }
315
+ // Note: Container size is handled by ResizeObserver - no need for getBoundingClientRect()
316
+ }
317
+
318
+ // Also try to load the image programmatically to handle both scenarios
319
+ const img = new Image();
320
+ img.crossOrigin = 'anonymous';
321
+ img.src = descriptor.src;
322
+
323
+ const handleLoad = () => {
324
+ setIsReady(true);
325
+
326
+ // Detect aspect ratio when programmatically loaded
327
+ if (!artworkAspectRatio && img.naturalWidth && img.naturalHeight) {
328
+ setDetectedAspectRatio(img.naturalWidth / img.naturalHeight);
329
+ }
330
+ };
331
+
332
+ img.addEventListener("load", handleLoad);
333
+
334
+ return () => {
335
+ img.removeEventListener("load", handleLoad);
336
+ };
337
+ }, [descriptor?.src, artworkAspectRatio]);
338
+
339
+ // Calculate mask dimensions
340
+ const maskDimensions = useMemo(() => {
341
+ if (!isReady || containerSize.width === 0 || !descriptor) {
342
+ return { width: 0, height: 0 };
343
+ }
344
+
345
+ return {
346
+ width:
347
+ descriptor.effectiveAlignment === "horizontal"
348
+ ? containerSize.height * descriptor.maskAspectRatio
349
+ : containerSize.width,
350
+ height:
351
+ descriptor.effectiveAlignment === "horizontal"
352
+ ? containerSize.height
353
+ : containerSize.width / descriptor.maskAspectRatio,
354
+ };
355
+ }, [
356
+ containerSize.width,
357
+ containerSize.height,
358
+ descriptor?.maskAspectRatio,
359
+ descriptor?.effectiveAlignment,
360
+ isReady,
361
+ ]);
362
+
363
+ // Pre-calculate clip-path coordinate helpers to avoid recalculation during drag
364
+ const clipPathCoords = useMemo(() => {
365
+ const halfMaskW = maskDimensions.width / 2;
366
+ const halfMaskH = maskDimensions.height / 2;
367
+ const halfContainerW = containerSize.width / 2;
368
+ const halfContainerH = containerSize.height / 2;
369
+ return { halfMaskW, halfMaskH, halfContainerW, halfContainerH };
370
+ }, [maskDimensions.width, maskDimensions.height, containerSize.width, containerSize.height]);
371
+
372
+ // Update position when alignment changes or component is ready
373
+ useEffect(() => {
374
+ // Skip if position was recently set by user drag to prevent "boomerang" effect
375
+ // Context updates can arrive with variable delay, so we use a 500ms window
376
+ if (Date.now() - lastUserDragTimeRef.current < 500) return;
377
+ if (!isReady || maskDimensions.width === 0 || !descriptor) return;
378
+
379
+ const points = getSnapPoints(
380
+ containerSize,
381
+ maskDimensions,
382
+ descriptor.effectiveAlignment
383
+ );
384
+ const newPosition = {
385
+ x:
386
+ descriptor.effectiveAlignment === "horizontal"
387
+ ? points[alignment] || 0
388
+ : 0,
389
+ y:
390
+ descriptor.effectiveAlignment === "vertical"
391
+ ? points[alignment] || 0
392
+ : 0,
393
+ };
394
+
395
+ setPosition(newPosition);
396
+ }, [
397
+ alignment,
398
+ descriptor?.effectiveAlignment,
399
+ containerSize.width,
400
+ containerSize.height,
401
+ maskDimensions.width,
402
+ maskDimensions.height,
403
+ isReady,
404
+ ]);
405
+
406
+ // Memoize overlay style - uses pre-calculated coords for simpler template literal
407
+ const overlayStyle = useMemo((): CSSProperties => {
408
+ const { halfMaskW, halfMaskH, halfContainerW, halfContainerH } = clipPathCoords;
409
+ // Pre-calculate corner positions to reduce template literal complexity
410
+ const cx = position.x + halfContainerW;
411
+ const cy = position.y + halfContainerH;
412
+ const right = cx + halfMaskW;
413
+ const left = cx - halfMaskW;
414
+ const bottom = cy + halfMaskH;
415
+ const top = cy - halfMaskH;
416
+
417
+ return {
418
+ position: "absolute",
419
+ top: 0,
420
+ left: 0,
421
+ right: 0,
422
+ bottom: 0,
423
+ backgroundColor: "rgba(0, 0, 0, 0.3)",
424
+ backdropFilter: "blur(3px)",
425
+ WebkitBackdropFilter: "blur(3px)",
426
+ zIndex: 1,
427
+ clipPath: isReady
428
+ ? `polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 0%, ${right}px ${bottom}px, ${right}px ${top}px, ${left}px ${top}px, ${left}px ${bottom}px, ${right}px ${bottom}px)`
429
+ : "none",
430
+ };
431
+ }, [isReady, position.x, position.y, clipPathCoords]);
432
+
433
+ // Memoize mask style - doesn't depend on position (transform applied separately in JSX)
434
+ const maskStyle = useMemo((): CSSProperties => ({
435
+ width: `${maskDimensions.width}px`,
436
+ height: `${maskDimensions.height}px`,
437
+ boxShadow: `0 0 0 2px var(--color-background), 0 0 0 4px var(--color-primary)`,
438
+ position: "absolute",
439
+ zIndex: 2,
440
+ boxSizing: "border-box",
441
+ cursor: "grab",
442
+ touchAction: "none",
443
+ top: "50%",
444
+ left: "50%",
445
+ willChange: "transform", // GPU acceleration for smoother drag
446
+ borderRadius: "var(--radius-image, 4px)",
447
+ }), [maskDimensions.width, maskDimensions.height]);
448
+
449
+ // Memoize clear image clip-path - reuses same coords as overlay
450
+ const clearImageClipPath = useMemo(() => {
451
+ if (!isReady) return "none";
452
+ const { halfMaskW, halfMaskH, halfContainerW, halfContainerH } = clipPathCoords;
453
+ const cx = position.x + halfContainerW;
454
+ const cy = position.y + halfContainerH;
455
+ const right = cx + halfMaskW;
456
+ const left = cx - halfMaskW;
457
+ const bottom = cy + halfMaskH;
458
+ const top = cy - halfMaskH;
459
+ return `polygon(${left}px ${top}px, ${right}px ${top}px, ${right}px ${bottom}px, ${left}px ${bottom}px)`;
460
+ }, [isReady, position.x, position.y, clipPathCoords]);
461
+
462
+ // Memoize mask transform - the dynamic position part
463
+ const maskTransform = useMemo(() => {
464
+ return descriptor?.effectiveAlignment === "horizontal"
465
+ ? `translate(calc(-50% + ${position.x}px), -50%)`
466
+ : `translate(-50%, calc(-50% + ${position.y}px))`;
467
+ }, [descriptor?.effectiveAlignment, position.x, position.y]);
468
+
469
+ // Throttle context updates to prevent iOS Safari crashes
470
+ // Track last context update time and pending alignment
471
+ const lastContextUpdateTimeRef = useRef(0);
472
+ const pendingAlignmentRef = useRef<ImageAlignment | null>(null);
473
+ const throttleTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
474
+ const THROTTLE_MS = 150; // Update context at most every 150ms during drag
475
+
476
+ const bind = useDrag(
477
+ ({ offset: [ox, oy], last }: { offset: [number, number]; last: boolean }) => {
478
+ if (
479
+ maskDimensions.width === 0 ||
480
+ maskDimensions.height === 0 ||
481
+ !descriptor
482
+ ) {
483
+ return;
484
+ }
485
+
486
+ const movement = descriptor.effectiveAlignment === "horizontal" ? ox : oy;
487
+
488
+ const maxDistance =
489
+ descriptor.effectiveAlignment === "horizontal"
490
+ ? (containerSize.width - maskDimensions.width) / 2
491
+ : (containerSize.height - maskDimensions.height) / 2;
492
+
493
+ const clampedMovement = Math.max(
494
+ Math.min(movement, maxDistance),
495
+ -maxDistance
496
+ );
497
+
498
+ // Find closest snap point
499
+ const positions = Object.entries(
500
+ getSnapPoints(
501
+ containerSize,
502
+ maskDimensions,
503
+ descriptor.effectiveAlignment
504
+ )
505
+ );
506
+ const [closestPosition] = positions.reduce(
507
+ (nearest, [pos, value]) => {
508
+ const distance = Math.abs(clampedMovement - value);
509
+ return distance < nearest[1] ? [pos, distance] : nearest;
510
+ },
511
+ ["center", Infinity]
512
+ );
513
+
514
+ // Get the actual position value for the closest snap point
515
+ const snapPoints = getSnapPoints(
516
+ containerSize,
517
+ maskDimensions,
518
+ descriptor.effectiveAlignment
519
+ );
520
+ const snappedPosition =
521
+ snapPoints[closestPosition as keyof typeof snapPoints];
522
+
523
+ // Record drag time to prevent sync effect from causing "boomerang"
524
+ lastUserDragTimeRef.current = Date.now();
525
+
526
+ // Calculate new position
527
+ const newPosition = {
528
+ x:
529
+ descriptor.effectiveAlignment === "horizontal"
530
+ ? snappedPosition ?? 0
531
+ : 0,
532
+ y:
533
+ descriptor.effectiveAlignment === "vertical"
534
+ ? snappedPosition ?? 0
535
+ : 0,
536
+ };
537
+
538
+ // Only update position state if it actually changed (prevents unnecessary re-renders)
539
+ const positionChanged = newPosition.x !== position.x || newPosition.y !== position.y;
540
+
541
+ if (positionChanged) {
542
+ setPosition(newPosition);
543
+ }
544
+
545
+ // Track alignment change for throttled context update
546
+ if (closestPosition !== alignment) {
547
+ pendingAlignmentRef.current = closestPosition as ImageAlignment;
548
+ }
549
+
550
+ // Helper to commit alignment to context
551
+ const commitAlignment = () => {
552
+ if (pendingAlignmentRef.current !== null && placement && designContext) {
553
+ designContext.setPlacementDesign(placement, {
554
+ alignment: pendingAlignmentRef.current,
555
+ });
556
+ lastContextUpdateTimeRef.current = Date.now();
557
+ pendingAlignmentRef.current = null;
558
+ }
559
+ };
560
+
561
+ // On drag end, clear any pending timeout and commit immediately
562
+ if (last) {
563
+ if (throttleTimeoutRef.current) {
564
+ clearTimeout(throttleTimeoutRef.current);
565
+ throttleTimeoutRef.current = null;
566
+ }
567
+ commitAlignment();
568
+ return;
569
+ }
570
+
571
+ // During drag, throttle context updates to prevent iOS Safari crashes
572
+ if (pendingAlignmentRef.current !== null) {
573
+ const now = Date.now();
574
+ const timeSinceLastUpdate = now - lastContextUpdateTimeRef.current;
575
+
576
+ if (timeSinceLastUpdate >= THROTTLE_MS) {
577
+ // Enough time has passed, update immediately
578
+ commitAlignment();
579
+ } else if (!throttleTimeoutRef.current) {
580
+ // Schedule an update for when throttle period ends
581
+ throttleTimeoutRef.current = setTimeout(() => {
582
+ throttleTimeoutRef.current = null;
583
+ commitAlignment();
584
+ }, THROTTLE_MS - timeSinceLastUpdate);
585
+ }
586
+ }
587
+ },
588
+ {
589
+ from: () => [position.x, position.y],
590
+ bounds: () => {
591
+ if (maskDimensions.width === 0 || maskDimensions.height === 0) {
592
+ return { left: 0, right: 0, top: 0, bottom: 0 };
593
+ }
594
+
595
+ const maxDistanceX = (containerSize.width - maskDimensions.width) / 2;
596
+ const maxDistanceY = (containerSize.height - maskDimensions.height) / 2;
597
+
598
+ return {
599
+ left: -maxDistanceX,
600
+ right: maxDistanceX,
601
+ top: -maxDistanceY,
602
+ bottom: maxDistanceY,
603
+ };
604
+ },
605
+ }
606
+ );
607
+
608
+ // Early return AFTER all hooks
609
+ if (!descriptor) {
610
+ return null;
611
+ }
612
+
613
+ return (
614
+ <div
615
+ className={`relative ${className || ""}`}
616
+ style={{
617
+ display: "inline-block",
618
+ width: "fit-content",
619
+ maxWidth: "100%",
620
+ }}
621
+ >
622
+ <div className="overflow-hidden p-1">
623
+ <div ref={containerRef} className="relative" style={containerStyle}>
624
+ {/* Background image with blur */}
625
+ <img
626
+ ref={imageRef}
627
+ src={descriptor.src}
628
+ alt="Masked"
629
+ crossOrigin="anonymous"
630
+ className="h-full w-full block"
631
+ style={{
632
+ backgroundColor: "#fff",
633
+ backgroundPosition: "0 0, 10px 10px",
634
+ backgroundImage: `
635
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
636
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
637
+ `,
638
+ backgroundSize: "20px 20px, 20px 20px",
639
+ objectFit:
640
+ descriptor.effectiveAlignment === "vertical"
641
+ ? "cover"
642
+ : "contain",
643
+ objectPosition: "center",
644
+ maskImage: "linear-gradient(circle, white 70%, transparent 71%)",
645
+ WebkitMaskImage:
646
+ "linear-gradient(circle, white 70%, transparent 71%)",
647
+ }}
648
+ onLoad={handleImageLoad}
649
+ loading="eager"
650
+ />
651
+
652
+ {isReady && maskDimensions.width > 0 && (
653
+ <>
654
+ {/* Overlay with blur - excludes the selected area */}
655
+ <div style={overlayStyle} />
656
+
657
+ {/* Clear image copy for selected area only - sits on top */}
658
+ <img
659
+ src={descriptor.src}
660
+ alt="Selected area"
661
+ crossOrigin="anonymous"
662
+ className="h-full w-full block"
663
+ style={{
664
+ position: "absolute",
665
+ top: 0,
666
+ left: 0,
667
+ backgroundColor: "#fff",
668
+ backgroundPosition: "0 0, 10px 10px",
669
+ backgroundImage: `
670
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5),
671
+ linear-gradient(45deg, #e5e5e5 25%, transparent 25%, transparent 75%, #e5e5e5 75%, #e5e5e5)
672
+ `,
673
+ backgroundSize: "20px 20px, 20px 20px",
674
+ objectFit:
675
+ descriptor.effectiveAlignment === "vertical"
676
+ ? "cover"
677
+ : "contain",
678
+ objectPosition: "center",
679
+ zIndex: 1,
680
+ maskImage:
681
+ "linear-gradient(circle, white 70%, transparent 71%)",
682
+ WebkitMaskImage:
683
+ "linear-gradient(circle, white 70%, transparent 71%)",
684
+ clipPath: clearImageClipPath,
685
+ }}
686
+ loading="eager"
687
+ />
688
+
689
+ {/* Selection frame */}
690
+ <div
691
+ {...bind()}
692
+ style={{
693
+ ...maskStyle,
694
+ transform: maskTransform,
695
+ }}
696
+ />
697
+ </>
698
+ )}
699
+ </div>
700
+ </div>
701
+ </div>
702
+ );
703
+ }