@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,518 @@
1
+ /**
2
+ * CanvasExportService - Singleton for zero-lag canvas exports
3
+ *
4
+ * This service provides a STABLE callback reference for SnowconeCanvas's onExport prop.
5
+ * By keeping the callback outside React's render cycle, we prevent re-renders from
6
+ * causing canvas drag lag.
7
+ *
8
+ * Architecture:
9
+ * - The service holds mutable state (refs to functions and values)
10
+ * - React components configure the service via `configure()` on mount/updates
11
+ * - The `handleExport` method is a stable reference that never changes
12
+ * - During drag, canvas calls `handleExport` which reads current values from instance
13
+ *
14
+ * Memory Management (iOS Safari crash prevention):
15
+ * - Blob URLs are revoked immediately when new ones arrive
16
+ * - ImageBitmap cache is cleared when exiting editor mode
17
+ * - DataURL conversion is lazy (only on getLastExportedArtwork call, not every export)
18
+ *
19
+ * Usage:
20
+ * ```tsx
21
+ * // In your component
22
+ * useEffect(() => {
23
+ * canvasExportService.configure({
24
+ * sendCanvasBlob: productContext.sendCanvasBlob,
25
+ * mockupCount: productContext.product?.mockups?.length ?? 1,
26
+ * isRealtimeEnabled: productContext.realtime?.isEnabled ?? false,
27
+ * onArtworkChange: (src) => setCurrentArtwork({ src, type: 'regular' }),
28
+ * });
29
+ * }, [productContext.sendCanvasBlob, ...]);
30
+ *
31
+ * // Pass stable reference - never causes re-render
32
+ * <SnowconeCanvas onExport={canvasExportService.handleExport} />
33
+ * ```
34
+ */
35
+
36
+ export interface CanvasExportServiceConfig {
37
+ /** Function to send blob to realtime mockup service (PNG mode) */
38
+ sendCanvasBlob?: (placement: string, blob: Blob, expectedMockupCount: number, throttleMs?: number) => void;
39
+ /** Function to send canvas state JSON to realtime mockup service (JSON mode — server-side rendering) */
40
+ sendCanvasState?: (placement: string, state: object, expectedMockupCount: number, throttleMs?: number) => boolean;
41
+ /**
42
+ * Function to serialize canvas onChange state to server render format.
43
+ * Pass `serializeStateForServer` from @snowcone-app/canvas.
44
+ * Required for JSON mode — if not provided, falls back to PNG mode.
45
+ */
46
+ serializeStateForServer?: (state: any) => any;
47
+ /** Number of mockups to expect (for throttling) */
48
+ mockupCount?: number;
49
+ /** Whether realtime mockups are enabled */
50
+ isRealtimeEnabled?: boolean;
51
+ /** Current placement name (e.g., "Front") */
52
+ placementName?: string;
53
+ /** Callback when artwork changes (called on exit from editor, not during drag) */
54
+ onArtworkChange?: (src: string) => void;
55
+ /** Enable realtime function (called when entering editor mode) */
56
+ enableRealtime?: () => void;
57
+ /** Disable realtime function (called when exiting editor mode) */
58
+ disableRealtime?: () => void;
59
+ /**
60
+ * Function to clear ImageBitmap cache (memory management for iOS Safari)
61
+ * Pass clearImageBitmapCache from @snowcone-app/canvas
62
+ */
63
+ clearImageBitmapCache?: () => void;
64
+ /**
65
+ * Called when handleExport runs but ends up sending nothing (skipBlob=true
66
+ * AND no JSON state was sent in this call). Pairs with the optimistic
67
+ * `notifyPendingExport` from RealtimeProvider so consumers (loading
68
+ * overlays, shimmer effects) can roll back state when an export turns out
69
+ * to be a no-op. Wire to `realtimeContext.notifyPendingExportCancelled`.
70
+ */
71
+ notifyExportSkipped?: (placement: string) => void;
72
+ }
73
+
74
+ class CanvasExportService {
75
+ // Configuration - updated by React components
76
+ private sendCanvasBlob: CanvasExportServiceConfig['sendCanvasBlob'] = undefined;
77
+ private sendCanvasState: CanvasExportServiceConfig['sendCanvasState'] = undefined;
78
+ private serializeStateForServer: CanvasExportServiceConfig['serializeStateForServer'] = undefined;
79
+ private mockupCount = 1;
80
+ private isRealtimeEnabled = false;
81
+ private placementName = 'Front';
82
+ private onArtworkChange: CanvasExportServiceConfig['onArtworkChange'] = undefined;
83
+ private enableRealtime: CanvasExportServiceConfig['enableRealtime'] = undefined;
84
+ private disableRealtime: CanvasExportServiceConfig['disableRealtime'] = undefined;
85
+ private clearImageBitmapCache: CanvasExportServiceConfig['clearImageBitmapCache'] = undefined;
86
+ private notifyExportSkipped: CanvasExportServiceConfig['notifyExportSkipped'] = undefined;
87
+
88
+ // Cache latest canvas state for replay when WS connects
89
+ private pendingCanvasState: any = null;
90
+ // Track whether JSON mode has successfully sent at least once
91
+ private jsonModeSentSuccessfully = false;
92
+
93
+ // Debounce state for handleStateChange
94
+ private stateChangeThrottleTimeout: ReturnType<typeof setTimeout> | null = null;
95
+ private stateChangeLastSendTime = 0;
96
+ /** How long to wait after last change before sending (quick discrete actions) */
97
+ private static SETTLE_MS = 300;
98
+ /** During continuous activity (drag), force a send at least this often */
99
+ private static MAX_WAIT_MS = 1000;
100
+
101
+ // Internal state for tracking exports
102
+ private lastExportedArtwork: string | null = null;
103
+ private initialArtwork: string | null = null;
104
+ private blobUrl: string | null = null;
105
+ // Store the last blob directly instead of converting to DataURL on every export
106
+ // This reduces memory pressure on iOS Safari - DataURL conversion creates large strings
107
+ private lastExportedBlob: Blob | null = null;
108
+
109
+ /**
110
+ * Configure the service with current values from React context.
111
+ * Call this in useEffect when context values change.
112
+ * This does NOT cause re-renders - it just updates internal state.
113
+ */
114
+ configure(config: CanvasExportServiceConfig): void {
115
+ if (config.sendCanvasBlob !== undefined) {
116
+ this.sendCanvasBlob = config.sendCanvasBlob;
117
+ }
118
+ if (config.sendCanvasState !== undefined) {
119
+ this.sendCanvasState = config.sendCanvasState;
120
+ }
121
+ if (config.serializeStateForServer !== undefined) {
122
+ this.serializeStateForServer = config.serializeStateForServer;
123
+ }
124
+ if (config.mockupCount !== undefined) {
125
+ this.mockupCount = config.mockupCount;
126
+ }
127
+ if (config.isRealtimeEnabled !== undefined) {
128
+ this.isRealtimeEnabled = config.isRealtimeEnabled;
129
+ }
130
+ if (config.placementName !== undefined) {
131
+ this.placementName = config.placementName;
132
+ }
133
+ if (config.onArtworkChange !== undefined) {
134
+ this.onArtworkChange = config.onArtworkChange;
135
+ }
136
+ if (config.enableRealtime !== undefined) {
137
+ this.enableRealtime = config.enableRealtime;
138
+ }
139
+ if (config.disableRealtime !== undefined) {
140
+ this.disableRealtime = config.disableRealtime;
141
+ }
142
+ if (config.clearImageBitmapCache !== undefined) {
143
+ this.clearImageBitmapCache = config.clearImageBitmapCache;
144
+ }
145
+ if (config.notifyExportSkipped !== undefined) {
146
+ this.notifyExportSkipped = config.notifyExportSkipped;
147
+ }
148
+
149
+ // Replay pending canvas state when JSON mode becomes fully available
150
+ if (this.pendingCanvasState && this.isRealtimeEnabled && this.sendCanvasState && this.serializeStateForServer) {
151
+ const pending = this.pendingCanvasState;
152
+ this.pendingCanvasState = null;
153
+ // Defer to avoid calling sendCanvasState during configure
154
+ queueMicrotask(() => this.handleStateChange(pending));
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Set the initial artwork when entering editor mode.
160
+ * This is used to detect if artwork changed when exiting.
161
+ */
162
+ setInitialArtwork(src: string | null): void {
163
+ this.initialArtwork = src;
164
+ this.lastExportedArtwork = src;
165
+ }
166
+
167
+ /**
168
+ * Get the last exported artwork (for saving on exit)
169
+ * Uses lazy DataURL conversion - only converts when called, not on every export
170
+ * Returns a Promise to support async blob-to-DataURL conversion
171
+ */
172
+ getLastExportedArtwork(): string | null {
173
+ // If we have a cached DataURL string, return it
174
+ if (this.lastExportedArtwork) {
175
+ return this.lastExportedArtwork;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Get the last exported artwork as DataURL (async version)
182
+ * Converts from blob on demand to reduce memory pressure during editing
183
+ */
184
+ async getLastExportedArtworkAsync(): Promise<string | null> {
185
+ // If we have a cached DataURL string, return it
186
+ if (this.lastExportedArtwork) {
187
+ return this.lastExportedArtwork;
188
+ }
189
+ // If we have a stored blob, convert it to DataURL now (lazy conversion)
190
+ if (this.lastExportedBlob) {
191
+ return new Promise((resolve) => {
192
+ const reader = new FileReader();
193
+ reader.onloadend = () => {
194
+ this.lastExportedArtwork = reader.result as string;
195
+ // Clear the blob after conversion to free memory
196
+ this.lastExportedBlob = null;
197
+ resolve(this.lastExportedArtwork);
198
+ };
199
+ reader.onerror = () => resolve(null);
200
+ reader.readAsDataURL(this.lastExportedBlob as Blob);
201
+ });
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Check if artwork has changed since entering editor
208
+ */
209
+ hasArtworkChanged(): boolean {
210
+ // Check if we have any export (either cached DataURL or stored blob)
211
+ const hasExport = this.lastExportedArtwork !== null || this.lastExportedBlob !== null;
212
+ // Consider changed if we have an export and it differs from initial
213
+ // (or if we have a blob, meaning the user made edits)
214
+ return hasExport && (
215
+ this.lastExportedArtwork !== this.initialArtwork ||
216
+ this.lastExportedBlob !== null
217
+ );
218
+ }
219
+
220
+ /**
221
+ * Call when entering editor mode to enable realtime if available
222
+ */
223
+ onEnterEditorMode(): void {
224
+ if (this.enableRealtime && !this.isRealtimeEnabled) {
225
+ this.enableRealtime();
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Call when exiting editor mode to cleanup and disable realtime
231
+ * CRITICAL: Clears ImageBitmap cache to prevent iOS Safari memory crashes
232
+ */
233
+ onExitEditorMode(): void {
234
+ // Disable realtime mode so hero images stop showing realtime mockups
235
+ if (this.disableRealtime) {
236
+ this.disableRealtime();
237
+ }
238
+
239
+ // Cleanup blob URL
240
+ if (this.blobUrl) {
241
+ URL.revokeObjectURL(this.blobUrl);
242
+ this.blobUrl = null;
243
+ }
244
+
245
+ // Clear ImageBitmap cache - CRITICAL for iOS Safari memory management
246
+ // Each cached ImageBitmap can consume 16MB+ for large images
247
+ // Without this cleanup, memory accumulates and causes Safari to crash
248
+ if (this.clearImageBitmapCache) {
249
+ this.clearImageBitmapCache();
250
+ }
251
+
252
+ // Reset state
253
+ this.initialArtwork = null;
254
+ this.lastExportedArtwork = null;
255
+ this.lastExportedBlob = null;
256
+ }
257
+
258
+ /**
259
+ * Cleanup realtime resources without disabling realtime mode
260
+ * Use for iOS Safari memory pressure situations where we need to free memory
261
+ * but want to keep realtime mode active
262
+ */
263
+ cleanupRealtimeResources(): void {
264
+ // Revoke any stored blob URL
265
+ if (this.blobUrl) {
266
+ URL.revokeObjectURL(this.blobUrl);
267
+ this.blobUrl = null;
268
+ }
269
+
270
+ // Clear stored blob to free memory
271
+ this.lastExportedBlob = null;
272
+
273
+ // Clear DataURL string if it's large
274
+ // Keep initialArtwork for change detection but clear lastExportedArtwork
275
+ // since the blob was already sent to realtime service
276
+ if (this.lastExportedArtwork && this.lastExportedArtwork.length > 100000) {
277
+ this.lastExportedArtwork = null;
278
+ }
279
+
280
+ // Clear ImageBitmap cache to release GPU memory
281
+ if (this.clearImageBitmapCache) {
282
+ this.clearImageBitmapCache();
283
+ }
284
+ }
285
+
286
+ /**
287
+ * STABLE export handler - pass this to SnowconeCanvas onExport prop.
288
+ * This method reference NEVER changes, preventing re-renders.
289
+ *
290
+ * CRITICAL: This is an arrow function assigned to an instance property,
291
+ * so `this` is always bound correctly and the reference is stable.
292
+ *
293
+ * Handles both single artboard exports and multi-artboard exports:
294
+ * - Single: { 'Front': blob } - sends as 'Front' placement
295
+ * - Multi: { 'Front': blob1, 'Back': blob2 } - sends each with its artboard name as placement
296
+ */
297
+ handleExport = async (exports: Record<string, string | Blob>): Promise<void> => {
298
+ const artboardNames = Object.keys(exports);
299
+ if (artboardNames.length === 0) return;
300
+
301
+ // Process the first (or active) artboard for local storage
302
+ const primaryArtboardName = artboardNames[0];
303
+ const primaryExportData = exports[artboardNames[0]];
304
+
305
+ // Store for saving on exit (works with both blob and dataUrl)
306
+ // MEMORY OPTIMIZATION: Store blob directly instead of converting to DataURL on every export
307
+ // DataURL conversion creates large strings (1.33x blob size due to base64 encoding)
308
+ // which causes memory pressure on iOS Safari. Conversion is done lazily in getLastExportedArtworkAsync()
309
+ if (primaryExportData instanceof Blob) {
310
+ // Store the blob directly - conversion to DataURL happens only when needed
311
+ this.lastExportedBlob = primaryExportData;
312
+ // Clear any cached DataURL since we have a newer blob
313
+ this.lastExportedArtwork = null;
314
+
315
+ // Store blob URL for potential future use
316
+ if (this.blobUrl) {
317
+ URL.revokeObjectURL(this.blobUrl);
318
+ }
319
+ this.blobUrl = URL.createObjectURL(primaryExportData);
320
+ } else if (typeof primaryExportData === 'string') {
321
+ // If it's already a DataURL string, store it directly
322
+ this.lastExportedArtwork = primaryExportData;
323
+ this.lastExportedBlob = null;
324
+ }
325
+
326
+ // Send blobs to realtime mockup service if enabled
327
+ // When JSON mode is active, handleStateChange sends state on every onChange.
328
+ // Only fall back to blob if JSON mode isn't configured at all.
329
+ const useJsonMode = this.isRealtimeEnabled && this.sendCanvasState && this.serializeStateForServer;
330
+ let jsonSentNow = false;
331
+ if (useJsonMode && this.pendingCanvasState) {
332
+ this.handleStateChange(this.pendingCanvasState);
333
+ jsonSentNow = !this.pendingCanvasState;
334
+ }
335
+ // Skip blob when JSON mode is configured — JSON is the primary realtime path.
336
+ // Blob is only used when JSON mode is not available (e.g., third-party canvas without serializeStateForServer).
337
+ const skipBlob = !!useJsonMode || this.jsonModeSentSuccessfully;
338
+ console.log(`[CES] handleExport: useJsonMode=${!!useJsonMode} pendingState=${!!this.pendingCanvasState} jsonSentNow=${jsonSentNow} jsonEverSent=${this.jsonModeSentSuccessfully} skipBlob=${skipBlob}`);
339
+
340
+ // If we ended up sending nothing — skipBlob is true AND no JSON state went
341
+ // out in this call — notify any optimistic pending-export listeners so
342
+ // they can roll back. Without this, an upstream `notifyPendingExport`
343
+ // would leave loading UI stuck (no `blobSent` / `mockupResults` will fire
344
+ // to clear it because no actual export happened).
345
+ if (skipBlob && !jsonSentNow) {
346
+ const placements = artboardNames.length > 0 ? artboardNames : [this.placementName];
347
+ placements.forEach((p) => {
348
+ try {
349
+ this.notifyExportSkipped?.(p);
350
+ } catch (err) {
351
+ console.warn(`[CES] notifyExportSkipped("${p}") threw:`, err);
352
+ }
353
+ });
354
+ }
355
+
356
+ if (this.isRealtimeEnabled && this.sendCanvasBlob && !skipBlob) {
357
+ // PNG fallback — used when sendCanvasState is not configured (third-party canvas)
358
+ for (const artboardName of artboardNames) {
359
+ const exportedData = exports[artboardName];
360
+ let blob: Blob;
361
+
362
+ if (exportedData instanceof Blob) {
363
+ blob = exportedData;
364
+ } else if (typeof exportedData === 'string') {
365
+ try {
366
+ const response = await fetch(exportedData);
367
+ blob = await response.blob();
368
+ } catch (error) {
369
+ console.error(`[CanvasExportService] Failed to convert dataURL to blob for artboard '${artboardName}':`, error);
370
+ continue;
371
+ }
372
+ } else {
373
+ continue;
374
+ }
375
+
376
+ const placement = artboardName;
377
+ this.sendCanvasBlob(placement, blob, this.mockupCount, 500);
378
+ }
379
+ }
380
+ };
381
+
382
+ /**
383
+ * STABLE state change handler - pass this to SnowconeCanvas onChange prop.
384
+ * Sends canvas state JSON to server for server-side rendering (JSON mode).
385
+ *
386
+ * When configured with sendCanvasState + serializeStateForServer, this replaces
387
+ * the blob upload path for realtime previews. The server renders the canvas
388
+ * instead of the client, eliminating the PNG upload bandwidth.
389
+ *
390
+ * handleExport still fires for local blob storage (save/exit, thumbnails).
391
+ */
392
+ private stateChangeCallCount = 0;
393
+
394
+ handleStateChange = (state: any): void => {
395
+ if (!state) return;
396
+
397
+ const hasElements = state.elements?.length > 0;
398
+ if (!hasElements) return;
399
+
400
+ // Don't send or cache state while any image is still loading —
401
+ // the serializer would skip it and the server would render without artwork
402
+ const hasLoadingImages = state.elements?.some(
403
+ (el: any) => (el.type === 'image' || el.transformType === 'image') && el.imageLoadState === 'loading'
404
+ );
405
+ if (hasLoadingImages) {
406
+ console.log(`[CES] handleStateChange: skipping — images still loading`);
407
+ return;
408
+ }
409
+
410
+ this.stateChangeCallCount++;
411
+ const callId = this.stateChangeCallCount;
412
+ const now = Date.now();
413
+ console.log(`[CES] handleStateChange #${callId} at ${now} (lastSend=${this.stateChangeLastSendTime}, elapsed=${this.stateChangeLastSendTime > 0 ? now - this.stateChangeLastSendTime : 'never'}ms, pendingTimer=${!!this.stateChangeThrottleTimeout})`);
414
+
415
+ // Cache for replay when WS connects
416
+ this.pendingCanvasState = state;
417
+
418
+ if (!this.isRealtimeEnabled || !this.sendCanvasState || !this.serializeStateForServer) {
419
+ console.log(`[CES] handleStateChange #${callId}: not ready (realtime=${this.isRealtimeEnabled} sendState=${!!this.sendCanvasState} serialize=${!!this.serializeStateForServer}) — state cached`);
420
+ return;
421
+ }
422
+
423
+ // Debounce with max wait: reset timer on each call so rapid drag frames
424
+ // coalesce, but if we haven't sent in >= throttleMs, flush immediately
425
+ // so long drags still get periodic updates.
426
+ const settleMs = CanvasExportService.SETTLE_MS;
427
+ const maxWaitMs = CanvasExportService.MAX_WAIT_MS;
428
+ const hasSentBefore = this.stateChangeLastSendTime > 0;
429
+ const timeSinceLastSend = hasSentBefore ? now - this.stateChangeLastSendTime : 0;
430
+
431
+ // Max wait: during a continuous drag, force a send if it's been >= maxWaitMs
432
+ // since last send. "Active" means last send was recent (within 3× maxWait).
433
+ const isActiveDrag = hasSentBefore && timeSinceLastSend < maxWaitMs * 3;
434
+ if (isActiveDrag && timeSinceLastSend >= maxWaitMs) {
435
+ console.log(`[CES] handleStateChange #${callId}: MAX-WAIT FLUSH (${timeSinceLastSend}ms >= ${maxWaitMs}ms)`);
436
+ if (this.stateChangeThrottleTimeout) {
437
+ clearTimeout(this.stateChangeThrottleTimeout);
438
+ this.stateChangeThrottleTimeout = null;
439
+ }
440
+ this.sendStateNow(state);
441
+ return;
442
+ }
443
+
444
+ // Settle timer: reset on every call, fires settleMs after last change.
445
+ // Handles discrete actions (type a letter, nudge, color pick).
446
+ if (this.stateChangeThrottleTimeout) {
447
+ clearTimeout(this.stateChangeThrottleTimeout);
448
+ }
449
+ console.log(`[CES] handleStateChange #${callId}: SETTLE scheduled ${settleMs}ms`);
450
+ this.stateChangeThrottleTimeout = setTimeout(() => {
451
+ this.stateChangeThrottleTimeout = null;
452
+ console.log(`[CES] handleStateChange #${callId}: SETTLE FIRED at ${Date.now()} (${Date.now() - now}ms after call)`);
453
+ if (this.pendingCanvasState) {
454
+ this.sendStateNow(this.pendingCanvasState);
455
+ }
456
+ }, settleMs);
457
+ };
458
+
459
+ private sendStateNow(state: any): void {
460
+ if (!this.sendCanvasState || !this.serializeStateForServer) {
461
+ console.log(`[CES] sendStateNow SKIPPED: sendCanvasState=${!!this.sendCanvasState} serializeStateForServer=${!!this.serializeStateForServer}`);
462
+ return;
463
+ }
464
+
465
+ this.stateChangeLastSendTime = Date.now();
466
+
467
+ const serverState = this.serializeStateForServer(state);
468
+ const artboards = serverState?.artboards;
469
+ if (!artboards || artboards.length === 0) {
470
+ console.log(`[CES] sendStateNow SKIPPED: no artboards in serialized state`);
471
+ return;
472
+ }
473
+
474
+ let sent = false;
475
+ for (const artboard of artboards) {
476
+ const placement = artboard.name || this.placementName;
477
+ const result = this.sendCanvasState(placement, serverState, this.mockupCount, 0);
478
+ console.log(`[CES] sendCanvasState("${placement}") → ${result ? 'OK' : 'FAILED (WS not ready)'}`);
479
+ if (result) {
480
+ sent = true;
481
+ }
482
+ }
483
+
484
+ if (sent) {
485
+ this.pendingCanvasState = null;
486
+ this.jsonModeSentSuccessfully = true;
487
+ console.log(`[CES] JSON state sent for ${artboards.length} artboard(s)`);
488
+ } else {
489
+ console.log(`[CES] JSON state send FAILED for all ${artboards.length} artboard(s) — state remains pending, jsonEverSent=${this.jsonModeSentSuccessfully}`);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Reset the service state (e.g., when unmounting)
495
+ */
496
+ reset(): void {
497
+ this.lastExportedArtwork = null;
498
+ this.lastExportedBlob = null;
499
+ this.initialArtwork = null;
500
+ if (this.blobUrl) {
501
+ URL.revokeObjectURL(this.blobUrl);
502
+ this.blobUrl = null;
503
+ }
504
+ // Clear pending state change throttle
505
+ if (this.stateChangeThrottleTimeout) {
506
+ clearTimeout(this.stateChangeThrottleTimeout);
507
+ this.stateChangeThrottleTimeout = null;
508
+ }
509
+ this.stateChangeLastSendTime = 0;
510
+ // Also clear ImageBitmap cache on full reset
511
+ if (this.clearImageBitmapCache) {
512
+ this.clearImageBitmapCache();
513
+ }
514
+ }
515
+ }
516
+
517
+ // Singleton instance - use this throughout the app
518
+ export const canvasExportService = new CanvasExportService();